feat(ui): 主题切换 + 语言持久化 + About 对话框

- 新增 light/dark 主题调色板与 Windows 注册表系统主题探测
- 偏好设置对话框:主题三选一 + 语言下拉(en/zh-CN/de),即时生效
- 持久化 Language/Theme/AutoLockMinutes/MinimizeToTray 至 YAML 顶层
- 启动时按 config 中保存的语言初始化 i18n
- About 对话框展示 version/runtime/项目地址/许可证
- 全部硬编码颜色迁移到 themePalette,为深色模式做准备
This commit is contained in:
2026-06-12 03:38:13 +08:00
parent c671f2115e
commit b483360778
19 changed files with 687 additions and 48 deletions
+51 -9
View File
@@ -10,12 +10,10 @@ import (
"time"
"gioui.org/app"
"gioui.org/font/gofont"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
@@ -95,6 +93,8 @@ type appState struct {
hotkeyDialog *hotkeyDialog
hotkeyTarget *entry
tradesDialog *steamTradesDialog
prefsDialog *preferencesDialog
aboutDialog *aboutDialog
store *store
saveErr string // surfaced in the top bar
@@ -102,6 +102,13 @@ type appState struct {
hkMgr *win32.HotkeyManager
toast toast
// themeMode is the user's current theme preference. drawFrame
// rebuilds the material.Theme when this changes.
themeMode themeMode
// theme is the cached material.Theme matching themeMode. nil forces a
// rebuild on the next frame.
theme *material.Theme
}
// snapshotEntries returns a freshly serialized slice of config entries.
@@ -119,9 +126,6 @@ func (st *appState) snapshotEntries() []config.Entry {
func loop(w *app.Window, configPath string) error {
const fn = "internal.ui.loop"
th := material.NewTheme()
th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
state := &appState{}
state.list.Axis = layout.Vertical
@@ -146,6 +150,12 @@ func loop(w *app.Window, configPath string) error {
state.absorbConfig(cfg)
}
// Seed the theme cache from the stored preference. Empty / unknown
// values resolve to "system" which buildTheme then maps to light or
// dark via the OS-specific systemPrefersDark probe.
_, themePref, _, _ := state.store.Preferences()
state.themeMode = normalizeThemeMode(themePref)
// Spin up the global hotkey manager and register whatever the user
// already had configured. Failures are non-fatal (logged + the row
// just won't fire).
@@ -170,7 +180,15 @@ func loop(w *app.Window, configPath string) error {
return e.Err
case app.FrameEvent:
gtx := app.NewContext(&ops, e)
drawFrame(gtx, th, state, w)
if state.theme == nil {
th, pal := buildTheme(state.themeMode)
state.theme = th
activePalette = pal
}
// Paint the window background using the active palette so
// dark mode does not show through as the Gio default grey.
fillBackground(gtx, activePalette.Background)
drawFrame(gtx, state.theme, state, w)
e.Frame(gtx.Ops)
}
}
@@ -320,8 +338,11 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
st.setPwDialog = newSetPasswordDialog()
case settingsActionImportLegacy:
st.importDialog = newImportLegacyDialog()
case settingsActionPreferences:
lang, theme, _, _ := st.store.Preferences()
st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme))
case settingsActionAbout:
// TODO: about dialog (next phase).
st.aboutDialog = newAboutDialog()
}
w.Invalidate()
} else {
@@ -329,6 +350,27 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
}
}
if st.prefsDialog != nil {
return st.prefsDialog.Layout(gtx, th, func(r preferencesResult) {
if !r.cancel {
_, _, autoLock, minToTray := st.store.Preferences()
st.store.SetPreferences(r.language, string(r.theme), autoLock, minToTray)
i18n.SetLanguage(r.language)
st.themeMode = r.theme
st.theme = nil // force rebuild on next frame
}
st.prefsDialog = nil
w.Invalidate()
})
}
if st.aboutDialog != nil {
return st.aboutDialog.Layout(gtx, th, func() {
st.aboutDialog = nil
w.Invalidate()
})
}
if st.importDialog != nil {
return st.importDialog.Layout(gtx, th, func(r importLegacyResult) {
if !r.cancel && r.cfg != nil {
@@ -427,7 +469,7 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
return layout.Dimensions{}
}
lbl := material.Body2(th, msg)
lbl.Color = color.NRGBA{R: 0xc0, A: 0xff}
lbl.Color = activePalette.ErrorFg
return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, lbl.Layout)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
@@ -487,7 +529,7 @@ func entryRow(gtx layout.Context, th *material.Theme, en *entry) layout.Dimensio
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.H6(th, en.Code)
lbl.Color = color.NRGBA{R: 0x10, G: 0x70, B: 0xff, A: 0xff}
lbl.Color = activePalette.RingFg
return lbl.Layout(gtx)
}),
)
+7 -9
View File
@@ -1,8 +1,6 @@
package ui
import (
"image/color"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
@@ -36,16 +34,16 @@ func modalCard(
cancelBtn *widget.Clickable,
body layout.Widget,
) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0, G: 0, B: 0, A: 0x60})
fillBackground(gtx, activePalette.ScrimBg)
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Max.X = gtx.Dp(420)
return widget.Border{
Color: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff},
Color: activePalette.DialogBorder,
CornerRadius: unit.Dp(4),
Width: unit.Dp(1),
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
fillBackground(gtx, activePalette.DialogBg)
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(th, title).Layout),
@@ -79,16 +77,16 @@ func modalCardCancel(
cancelBtn *widget.Clickable,
body layout.Widget,
) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0, G: 0, B: 0, A: 0x60})
fillBackground(gtx, activePalette.ScrimBg)
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Max.X = gtx.Dp(420)
return widget.Border{
Color: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff},
Color: activePalette.DialogBorder,
CornerRadius: unit.Dp(4),
Width: unit.Dp(1),
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
fillBackground(gtx, activePalette.DialogBg)
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(th, title).Layout),
@@ -114,7 +112,7 @@ func errorLabel(th *material.Theme, msg string) layout.Widget {
return layout.Dimensions{}
}
lbl := material.Body2(th, msg)
lbl.Color = color.NRGBA{R: 0xc0, A: 0xff}
lbl.Color = activePalette.ErrorFg
return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, lbl.Layout)
}
}
+55
View File
@@ -0,0 +1,55 @@
package ui
import (
"runtime"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
"git.wxccs.org/iceking2nd/winauth-go/internal/version"
)
// aboutDialog is a read-only popup describing the build, runtime and a
// short credits / project link blurb. Click "Close" to dismiss.
type aboutDialog struct {
closeBt widget.Clickable
}
func newAboutDialog() *aboutDialog { return &aboutDialog{} }
// Layout follows the simpler "single button" contract: onDone is called
// with no arguments when the user dismisses the dialog.
func (d *aboutDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(),
) layout.Dimensions {
if d.closeBt.Clicked(gtx) {
onDone()
return layout.Dimensions{Size: gtx.Constraints.Max}
}
body := func(gtx layout.Context) layout.Dimensions {
line := func(text string) layout.FlexChild {
return layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(2), Bottom: unit.Dp(2)}.Layout(gtx,
material.Body2(th, text).Layout)
})
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
line(i18n.T("about_app_name")),
line(i18n.T("about_version_label")+": "+version.String()),
line(i18n.T("about_runtime_label")+": "+runtime.Version()+" ("+runtime.GOOS+"/"+runtime.GOARCH+")"),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
line(i18n.T("about_project_label")+": "+i18n.T("about_project_url")),
line(i18n.T("about_license_label")+": "+i18n.T("about_license_value")),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
line(i18n.T("about_credits")),
)
}
return modalCardCancel(gtx, th, i18n.T("dialog_about_title"),
i18n.T("btn_close"), &d.closeBt, body)
}
+136
View File
@@ -0,0 +1,136 @@
package ui
import (
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// preferencesDialog edits the persistent UI prefs: UI language and
// theme. Auto-lock and minimize-to-tray live in their own bucket and
// will appear here later.
type preferencesDialog struct {
lang string
theme themeMode
// Theme selection — three "radio" buttons.
themeSystemBt widget.Clickable
themeLightBt widget.Clickable
themeDarkBt widget.Clickable
// Language selection — one button per supported locale. Cheap
// substitute for a dropdown until Gio gains a native one.
langEnBt widget.Clickable
langZhCNBt widget.Clickable
langDeBt widget.Clickable
okBt widget.Clickable
cancelBt widget.Clickable
}
// preferencesResult is what Layout's callback receives. cancel skips
// any persistence.
type preferencesResult struct {
cancel bool
language string
theme themeMode
}
// supportedLanguages is the closed set the prefs dialog exposes. Keep
// in sync with internal/i18n/locales/*.toml.
var supportedLanguages = []struct {
tag string
label string
}{
{"en", "English"},
{"zh-CN", "中文 (简体)"},
{"de", "Deutsch"},
}
func newPreferencesDialog(currentLang string, currentTheme themeMode) *preferencesDialog {
if currentLang == "" {
currentLang = "en"
}
if currentTheme == "" {
currentTheme = themeSystem
}
return &preferencesDialog{lang: currentLang, theme: currentTheme}
}
func (d *preferencesDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(preferencesResult),
) layout.Dimensions {
if d.cancelBt.Clicked(gtx) {
onDone(preferencesResult{cancel: true})
return layout.Dimensions{Size: gtx.Constraints.Max}
}
if d.okBt.Clicked(gtx) {
onDone(preferencesResult{language: d.lang, theme: d.theme})
return layout.Dimensions{Size: gtx.Constraints.Max}
}
// Map button clicks to model updates before the next layout.
switch {
case d.themeSystemBt.Clicked(gtx):
d.theme = themeSystem
case d.themeLightBt.Clicked(gtx):
d.theme = themeLight
case d.themeDarkBt.Clicked(gtx):
d.theme = themeDark
case d.langEnBt.Clicked(gtx):
d.lang = "en"
case d.langZhCNBt.Clicked(gtx):
d.lang = "zh-CN"
case d.langDeBt.Clicked(gtx):
d.lang = "de"
}
// Render one labeled "chip" — emphasised when selected, regular
// otherwise. Implemented as a material.Button so the click target
// stays generous without us hand-rolling a Pointer area.
chip := func(btn *widget.Clickable, label string, selected bool) layout.FlexChild {
return layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Right: unit.Dp(8), Bottom: unit.Dp(4)}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
b := material.Button(th, btn, label)
if !selected {
b.Background = activePalette.RingBg
b.Color = activePalette.OnBackground
}
return b.Layout(gtx)
})
})
}
body := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body1(th, i18n.T("label_theme")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
chip(&d.themeSystemBt, i18n.T("theme_system"), d.theme == themeSystem),
chip(&d.themeLightBt, i18n.T("theme_light"), d.theme == themeLight),
chip(&d.themeDarkBt, i18n.T("theme_dark"), d.theme == themeDark),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(material.Body1(th, i18n.T("label_language")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
chip(&d.langEnBt, supportedLanguages[0].label, d.lang == "en"),
chip(&d.langZhCNBt, supportedLanguages[1].label, d.lang == "zh-CN"),
chip(&d.langDeBt, supportedLanguages[2].label, d.lang == "de"),
)
}),
)
}
return modalCard(gtx, th, i18n.T("dialog_preferences_title"),
i18n.T("btn_ok"), i18n.T("btn_cancel"),
&d.okBt, &d.cancelBt, body)
}
+2 -3
View File
@@ -2,7 +2,6 @@ package ui
import (
"image"
"image/color"
"gioui.org/app"
"gioui.org/layout"
@@ -39,7 +38,7 @@ func (st *appState) copyCodeToClipboard(en *entry, w *app.Window) {
func drawEmptyPlaceholder(gtx layout.Context, th *material.Theme) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Body1(th, i18n.T("msg_empty_list"))
lbl.Color = color.NRGBA{R: 0x80, G: 0x80, B: 0x80, A: 0xff}
lbl.Color = activePalette.MutedFg
return layout.UniformInset(unit.Dp(8)).Layout(gtx, lbl.Layout)
})
}
@@ -50,7 +49,7 @@ func drawDivider(gtx layout.Context) layout.Dimensions {
h := gtx.Dp(unit.Dp(1))
size := image.Pt(gtx.Constraints.Max.X, h)
defer clip.Rect{Max: size}.Push(gtx.Ops).Pop()
paint.ColorOp{Color: color.NRGBA{R: 0xe0, G: 0xe0, B: 0xe0, A: 0xff}}.Add(gtx.Ops)
paint.ColorOp{Color: activePalette.Divider}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
return layout.Dimensions{Size: size}
}
+3 -4
View File
@@ -1,7 +1,6 @@
package ui
import (
"image/color"
"time"
"gioui.org/layout"
@@ -28,11 +27,11 @@ func entryProgressRing(gtx layout.Context, en *entry) layout.Dimensions {
remaining := int64(period) - elapsed
progress := float32(remaining) / float32(period)
fg := color.NRGBA{R: 0x10, G: 0x70, B: 0xff, A: 0xff}
fg := activePalette.RingFg
if remaining <= 5 {
fg = color.NRGBA{R: 0xd0, G: 0x30, B: 0x30, A: 0xff}
fg = activePalette.RingFgWarn
}
bg := color.NRGBA{R: 0xd8, G: 0xd8, B: 0xd8, A: 0xff}
bg := activePalette.RingBg
return progressRing{
Size: unit.Dp(ringDp),
+8 -5
View File
@@ -1,8 +1,6 @@
package ui
import (
"image/color"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
@@ -18,6 +16,7 @@ const (
settingsActionNone settingsAction = iota
settingsActionSetPassword
settingsActionImportLegacy
settingsActionPreferences
settingsActionAbout
)
@@ -26,6 +25,7 @@ const (
type settingsMenu struct {
setPwBtn widget.Clickable
importBtn widget.Clickable
prefsBtn widget.Clickable
aboutBtn widget.Clickable
cancelBtn widget.Clickable
}
@@ -39,6 +39,8 @@ func (m *settingsMenu) Pick(gtx layout.Context) (settingsAction, bool) {
return settingsActionSetPassword, true
case m.importBtn.Clicked(gtx):
return settingsActionImportLegacy, true
case m.prefsBtn.Clicked(gtx):
return settingsActionPreferences, true
case m.aboutBtn.Clicked(gtx):
return settingsActionAbout, true
case m.cancelBtn.Clicked(gtx):
@@ -48,7 +50,7 @@ func (m *settingsMenu) Pick(gtx layout.Context) (settingsAction, bool) {
}
func (m *settingsMenu) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0, G: 0, B: 0, A: 0x60})
fillBackground(gtx, activePalette.ScrimBg)
row := func(btn *widget.Clickable, label string) layout.FlexChild {
return layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -63,17 +65,18 @@ func (m *settingsMenu) Layout(gtx layout.Context, th *material.Theme) layout.Dim
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Max.X = gtx.Dp(280)
return widget.Border{
Color: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff},
Color: activePalette.DialogBorder,
CornerRadius: unit.Dp(4),
Width: unit.Dp(1),
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
fillBackground(gtx, activePalette.DialogBg)
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(th, i18n.T("menu_settings")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
row(&m.setPwBtn, i18n.T("menu_set_password")),
row(&m.importBtn, i18n.T("menu_import_legacy")),
row(&m.prefsBtn, i18n.T("menu_preferences")),
row(&m.aboutBtn, i18n.T("menu_about")),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+43 -3
View File
@@ -26,6 +26,13 @@ type store struct {
passphrase []byte
encrypted bool
// Top-level user preferences kept alongside entries so save() can
// roundtrip them without requiring the UI to rebuild a full Config.
language string
theme string
autoLockMinutes int
minimizeToTray bool
// dirty signals a save is pending. The worker reads & resets it.
dirty bool
snapshotFn func() []config.Entry
@@ -37,6 +44,27 @@ type store struct {
onError func(error)
}
// Preferences returns the persisted top-level prefs. UI code reads these
// after Load to seed dialog defaults.
func (s *store) Preferences() (language, theme string, autoLockMinutes int, minimizeToTray bool) {
s.mu.Lock()
defer s.mu.Unlock()
return s.language, s.theme, s.autoLockMinutes, s.minimizeToTray
}
// SetPreferences updates the persisted top-level prefs and schedules a
// save. Callers pass the current value for each field — there is no
// per-field "leave unchanged" sentinel.
func (s *store) SetPreferences(language, theme string, autoLockMinutes int, minimizeToTray bool) {
s.mu.Lock()
s.language = language
s.theme = theme
s.autoLockMinutes = autoLockMinutes
s.minimizeToTray = minimizeToTray
s.mu.Unlock()
s.Push()
}
// newStore initializes a store and starts the background save worker.
// snapshotFn is invoked whenever a save runs; it must return a freshly
// copied entries slice (the worker holds no UI locks). onError is called
@@ -86,6 +114,10 @@ func (s *store) Load(passphrase []byte) (*config.Config, error) {
s.mu.Lock()
s.passphrase = passphrase
s.encrypted = cfg.Encrypted
s.language = cfg.Language
s.theme = cfg.Theme
s.autoLockMinutes = cfg.AutoLockMinutes
s.minimizeToTray = cfg.MinimizeToTray
s.mu.Unlock()
logger.WithField("entries", len(cfg.Entries)).Debug("config loaded into store")
return cfg, nil
@@ -148,13 +180,21 @@ func (s *store) run() {
s.dirty = false
pw := append([]byte(nil), s.passphrase...)
enc := s.encrypted
lang := s.language
theme := s.theme
autoLock := s.autoLockMinutes
minToTray := s.minimizeToTray
s.mu.Unlock()
entries := s.snapshotFn()
cfg := &config.Config{
Version: 1,
Encrypted: enc,
Entries: entries,
Version: 1,
Language: lang,
Theme: theme,
AutoLockMinutes: autoLock,
MinimizeToTray: minToTray,
Encrypted: enc,
Entries: entries,
}
if err := config.SaveYAML(cfg, s.path, pw); err != nil {
global.Log.WithField("func", fn).WithError(err).Error("save failed")
+122
View File
@@ -0,0 +1,122 @@
// Package ui internal helpers — theme palette + system-theme detection.
package ui
import (
"image/color"
"gioui.org/font/gofont"
"gioui.org/text"
"gioui.org/widget/material"
)
// themeMode is one of the user-selectable theme settings.
type themeMode string
const (
themeSystem themeMode = "system"
themeLight themeMode = "light"
themeDark themeMode = "dark"
)
// themePalette holds the small set of colors the app itself draws
// (toast, dividers, ring background, etc). The material.Theme handles
// button + body text colors via its own palette.
type themePalette struct {
Background color.NRGBA
OnBackground color.NRGBA
Divider color.NRGBA
RingBg color.NRGBA
RingFg color.NRGBA
RingFgWarn color.NRGBA
ToastBg color.NRGBA
ToastFg color.NRGBA
ErrorFg color.NRGBA
DialogBg color.NRGBA
DialogBorder color.NRGBA
ScrimBg color.NRGBA // backdrop behind modal dialogs
MutedFg color.NRGBA // for placeholder / hint text
}
func lightPalette() themePalette {
return themePalette{
Background: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff},
OnBackground: color.NRGBA{R: 0x10, G: 0x10, B: 0x10, A: 0xff},
Divider: color.NRGBA{R: 0xe0, G: 0xe0, B: 0xe0, A: 0xff},
RingBg: color.NRGBA{R: 0xd8, G: 0xd8, B: 0xd8, A: 0xff},
RingFg: color.NRGBA{R: 0x10, G: 0x70, B: 0xff, A: 0xff},
RingFgWarn: color.NRGBA{R: 0xd0, G: 0x30, B: 0x30, A: 0xff},
ToastBg: color.NRGBA{R: 0x20, G: 0x20, B: 0x20, A: 0xe0},
ToastFg: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff},
ErrorFg: color.NRGBA{R: 0xc0, G: 0x00, B: 0x00, A: 0xff},
DialogBg: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff},
DialogBorder: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff},
ScrimBg: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x60},
MutedFg: color.NRGBA{R: 0x80, G: 0x80, B: 0x80, A: 0xff},
}
}
func darkPalette() themePalette {
return themePalette{
Background: color.NRGBA{R: 0x1e, G: 0x1e, B: 0x1e, A: 0xff},
OnBackground: color.NRGBA{R: 0xf0, G: 0xf0, B: 0xf0, A: 0xff},
Divider: color.NRGBA{R: 0x38, G: 0x38, B: 0x38, A: 0xff},
RingBg: color.NRGBA{R: 0x3a, G: 0x3a, B: 0x3a, A: 0xff},
RingFg: color.NRGBA{R: 0x4d, G: 0xa3, B: 0xff, A: 0xff},
RingFgWarn: color.NRGBA{R: 0xff, G: 0x6b, B: 0x6b, A: 0xff},
ToastBg: color.NRGBA{R: 0xf0, G: 0xf0, B: 0xf0, A: 0xee},
ToastFg: color.NRGBA{R: 0x10, G: 0x10, B: 0x10, A: 0xff},
ErrorFg: color.NRGBA{R: 0xff, G: 0x6b, B: 0x6b, A: 0xff},
DialogBg: color.NRGBA{R: 0x2a, G: 0x2a, B: 0x2a, A: 0xff},
DialogBorder: color.NRGBA{R: 0x60, G: 0x60, B: 0x60, A: 0xff},
ScrimBg: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x90},
MutedFg: color.NRGBA{R: 0x90, G: 0x90, B: 0x90, A: 0xff},
}
}
// buildTheme returns a fresh material.Theme + companion palette for the
// requested mode. "system" resolves via systemPrefersDark (Windows
// registry on win, false elsewhere).
func buildTheme(mode themeMode) (*material.Theme, themePalette) {
resolved := mode
if resolved == "" || resolved == themeSystem {
if systemPrefersDark() {
resolved = themeDark
} else {
resolved = themeLight
}
}
var pal themePalette
if resolved == themeDark {
pal = darkPalette()
} else {
pal = lightPalette()
}
th := material.NewTheme()
th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
th.Palette.Bg = pal.Background
th.Palette.Fg = pal.OnBackground
if resolved == themeDark {
th.Palette.ContrastBg = color.NRGBA{R: 0x4d, G: 0xa3, B: 0xff, A: 0xff}
th.Palette.ContrastFg = color.NRGBA{R: 0x10, G: 0x10, B: 0x10, A: 0xff}
}
return th, pal
}
// normalizeThemeMode coerces an arbitrary config string into one of the
// known modes. Unknown values fall back to "system".
func normalizeThemeMode(s string) themeMode {
switch themeMode(s) {
case themeLight, themeDark, themeSystem:
return themeMode(s)
}
return themeSystem
}
// activePalette holds the palette of the currently-rendered theme. Set
// once per frame at the top of drawFrame so shared helpers (toast,
// dividers, dialog backdrops) can colour themselves without dragging a
// palette argument through every signature. Gio invokes drawFrame on
// the single UI goroutine so plain assignment is safe.
var activePalette = lightPalette()
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package ui
// systemPrefersDark is the non-Windows stub; we cannot reliably read
// the OS theme preference, so default to light.
func systemPrefersDark() bool { return false }
+25
View File
@@ -0,0 +1,25 @@
//go:build windows
package ui
import "golang.org/x/sys/windows/registry"
// systemPrefersDark queries the Windows personalization registry key
// to find out whether the user picked dark mode for apps. Returns
// false on any read error so the default stays "light".
func systemPrefersDark() bool {
k, err := registry.OpenKey(
registry.CURRENT_USER,
`Software\Microsoft\Windows\CurrentVersion\Themes\Personalize`,
registry.QUERY_VALUE,
)
if err != nil {
return false
}
defer k.Close()
v, _, err := k.GetIntegerValue("AppsUseLightTheme")
if err != nil {
return false
}
return v == 0
}
+2 -3
View File
@@ -2,7 +2,6 @@ package ui
import (
"image"
"image/color"
"time"
"gioui.org/app"
@@ -48,8 +47,8 @@ func (t *toast) draw(gtx layout.Context, th *material.Theme) {
if !t.active() {
return
}
bg := color.NRGBA{R: 0x20, G: 0x20, B: 0x20, A: 0xe0}
fg := color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}
bg := activePalette.ToastBg
fg := activePalette.ToastFg
macro := op.Record(gtx.Ops)
pad := layout.UniformInset(unit.Dp(10))
+3 -5
View File
@@ -1,8 +1,6 @@
package ui
import (
"image/color"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
@@ -71,7 +69,7 @@ func (m *vendorMenu) Pick(gtx layout.Context) (vendor, bool) {
}
func (m *vendorMenu) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0, G: 0, B: 0, A: 0x60})
fillBackground(gtx, activePalette.ScrimBg)
row := func(btn *widget.Clickable, label string) layout.FlexChild {
return layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -86,11 +84,11 @@ func (m *vendorMenu) Layout(gtx layout.Context, th *material.Theme) layout.Dimen
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Max.X = gtx.Dp(320)
return widget.Border{
Color: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff},
Color: activePalette.DialogBorder,
CornerRadius: unit.Dp(4),
Width: unit.Dp(1),
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
fillBackground(gtx, activePalette.DialogBg)
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(th, i18n.T("menu_choose_vendor")).Layout),