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)
}),
)