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:
+26
-2
@@ -6,10 +6,12 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
|
||||||
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
|
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
|
||||||
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
|
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
|
||||||
"git.wxccs.org/iceking2nd/winauth-go/internal/logging"
|
"git.wxccs.org/iceking2nd/winauth-go/internal/logging"
|
||||||
"git.wxccs.org/iceking2nd/winauth-go/internal/ui"
|
"git.wxccs.org/iceking2nd/winauth-go/internal/ui"
|
||||||
|
"git.wxccs.org/iceking2nd/winauth-go/internal/version"
|
||||||
"git.wxccs.org/iceking2nd/winauth-go/internal/win32"
|
"git.wxccs.org/iceking2nd/winauth-go/internal/win32"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,7 +55,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fn = "cmd.winauth.main"
|
const fn = "cmd.winauth.main"
|
||||||
global.Log.WithField("func", fn).Info("winauth-go starting")
|
global.Log.WithField("func", fn).
|
||||||
|
WithField("version", version.String()).Info("winauth-go starting")
|
||||||
|
|
||||||
release, alreadyRunning, err := win32.AcquireInstanceLock(singleInstanceMutex)
|
release, alreadyRunning, err := win32.AcquireInstanceLock(singleInstanceMutex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -70,7 +73,12 @@ func main() {
|
|||||||
defer release()
|
defer release()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := i18n.Init(""); err != nil {
|
// Peek the config (without password) to recover the user's
|
||||||
|
// last language choice before initialising i18n. Encrypted
|
||||||
|
// configs still expose Language at the YAML top level so we
|
||||||
|
// can localise the password prompt itself.
|
||||||
|
lang := preferredLanguage(configPath)
|
||||||
|
if err := i18n.Init(lang); err != nil {
|
||||||
global.Log.WithField("func", fn).WithError(err).Warn("i18n init failed; falling back to keys")
|
global.Log.WithField("func", fn).WithError(err).Warn("i18n init failed; falling back to keys")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,3 +99,19 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// preferredLanguage reads the user's saved language tag out of the YAML
|
||||||
|
// config without requiring a passphrase. We resolve the default config
|
||||||
|
// path when configPath is blank and silently return "" on any failure;
|
||||||
|
// callers fall back to the i18n default in that case. Encrypted configs
|
||||||
|
// still expose the top-level Language field so this works for them too.
|
||||||
|
func preferredLanguage(configPath string) string {
|
||||||
|
if configPath == "" {
|
||||||
|
configPath = config.DefaultPath()
|
||||||
|
}
|
||||||
|
cfg, _ := config.LoadYAML(configPath, nil)
|
||||||
|
if cfg == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return cfg.Language
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,9 +21,17 @@ type Entry struct {
|
|||||||
// default; if Encrypted is true, EncryptedBlob holds a WAGO1 base64 ciphertext
|
// default; if Encrypted is true, EncryptedBlob holds a WAGO1 base64 ciphertext
|
||||||
// produced by internal/crypto.EncryptModern and Entries is empty on disk.
|
// produced by internal/crypto.EncryptModern and Entries is empty on disk.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Version int `yaml:"version" json:"version"`
|
Version int `yaml:"version" json:"version"`
|
||||||
Language string `yaml:"language,omitempty" json:"language,omitempty"`
|
Language string `yaml:"language,omitempty" json:"language,omitempty"`
|
||||||
Encrypted bool `yaml:"encrypted" json:"encrypted"`
|
// Theme is one of "", "light", "dark", "system" (empty = system).
|
||||||
EncryptedBlob string `yaml:"encrypted_blob,omitempty" json:"encrypted_blob,omitempty"`
|
Theme string `yaml:"theme,omitempty" json:"theme,omitempty"`
|
||||||
Entries []Entry `yaml:"entries,omitempty" json:"entries,omitempty"`
|
// AutoLockMinutes locks the UI (hides codes, requires password) after
|
||||||
|
// this many minutes of inactivity. 0 disables auto-lock.
|
||||||
|
AutoLockMinutes int `yaml:"auto_lock_minutes,omitempty" json:"auto_lock_minutes,omitempty"`
|
||||||
|
// MinimizeToTray sends the window to the system tray instead of
|
||||||
|
// exiting when the close button is pressed.
|
||||||
|
MinimizeToTray bool `yaml:"minimize_to_tray,omitempty" json:"minimize_to_tray,omitempty"`
|
||||||
|
Encrypted bool `yaml:"encrypted" json:"encrypted"`
|
||||||
|
EncryptedBlob string `yaml:"encrypted_blob,omitempty" json:"encrypted_blob,omitempty"`
|
||||||
|
Entries []Entry `yaml:"entries,omitempty" json:"entries,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -367,3 +367,55 @@ other = "QR-Scan fehlgeschlagen: %s"
|
|||||||
|
|
||||||
[msg_clipboard_no_image]
|
[msg_clipboard_no_image]
|
||||||
other = "Zwischenablage enthält kein Bild"
|
other = "Zwischenablage enthält kein Bild"
|
||||||
|
|
||||||
|
# --- Einstellungen ---
|
||||||
|
|
||||||
|
[menu_preferences]
|
||||||
|
other = "Einstellungen..."
|
||||||
|
|
||||||
|
[dialog_preferences_title]
|
||||||
|
other = "Einstellungen"
|
||||||
|
|
||||||
|
[label_theme]
|
||||||
|
other = "Design"
|
||||||
|
|
||||||
|
[theme_system]
|
||||||
|
other = "System"
|
||||||
|
|
||||||
|
[theme_light]
|
||||||
|
other = "Hell"
|
||||||
|
|
||||||
|
[theme_dark]
|
||||||
|
other = "Dunkel"
|
||||||
|
|
||||||
|
[label_language]
|
||||||
|
other = "Sprache"
|
||||||
|
|
||||||
|
# --- Über-Dialog ---
|
||||||
|
|
||||||
|
[dialog_about_title]
|
||||||
|
other = "Über WinAuth"
|
||||||
|
|
||||||
|
[about_app_name]
|
||||||
|
other = "WinAuth (Go-Portierung) — TOTP/HOTP-Authentifikator"
|
||||||
|
|
||||||
|
[about_version_label]
|
||||||
|
other = "Version"
|
||||||
|
|
||||||
|
[about_runtime_label]
|
||||||
|
other = "Laufzeit"
|
||||||
|
|
||||||
|
[about_project_label]
|
||||||
|
other = "Projekt"
|
||||||
|
|
||||||
|
[about_project_url]
|
||||||
|
other = "https://git.wxccs.org/iceking2nd/winauth-go"
|
||||||
|
|
||||||
|
[about_license_label]
|
||||||
|
other = "Lizenz"
|
||||||
|
|
||||||
|
[about_license_value]
|
||||||
|
other = "MIT"
|
||||||
|
|
||||||
|
[about_credits]
|
||||||
|
other = "Original-WinAuth von Colin Mackie. Go-Portierung wird von den Projektautoren gepflegt."
|
||||||
|
|||||||
@@ -369,3 +369,55 @@ other = "QR scan failed: %s"
|
|||||||
|
|
||||||
[msg_clipboard_no_image]
|
[msg_clipboard_no_image]
|
||||||
other = "Clipboard does not contain an image"
|
other = "Clipboard does not contain an image"
|
||||||
|
|
||||||
|
# --- Preferences ---
|
||||||
|
|
||||||
|
[menu_preferences]
|
||||||
|
other = "Preferences..."
|
||||||
|
|
||||||
|
[dialog_preferences_title]
|
||||||
|
other = "Preferences"
|
||||||
|
|
||||||
|
[label_theme]
|
||||||
|
other = "Theme"
|
||||||
|
|
||||||
|
[theme_system]
|
||||||
|
other = "System"
|
||||||
|
|
||||||
|
[theme_light]
|
||||||
|
other = "Light"
|
||||||
|
|
||||||
|
[theme_dark]
|
||||||
|
other = "Dark"
|
||||||
|
|
||||||
|
[label_language]
|
||||||
|
other = "Language"
|
||||||
|
|
||||||
|
# --- About dialog ---
|
||||||
|
|
||||||
|
[dialog_about_title]
|
||||||
|
other = "About WinAuth"
|
||||||
|
|
||||||
|
[about_app_name]
|
||||||
|
other = "WinAuth (Go port) — TOTP/HOTP authenticator"
|
||||||
|
|
||||||
|
[about_version_label]
|
||||||
|
other = "Version"
|
||||||
|
|
||||||
|
[about_runtime_label]
|
||||||
|
other = "Runtime"
|
||||||
|
|
||||||
|
[about_project_label]
|
||||||
|
other = "Project"
|
||||||
|
|
||||||
|
[about_project_url]
|
||||||
|
other = "https://git.wxccs.org/iceking2nd/winauth-go"
|
||||||
|
|
||||||
|
[about_license_label]
|
||||||
|
other = "License"
|
||||||
|
|
||||||
|
[about_license_value]
|
||||||
|
other = "MIT"
|
||||||
|
|
||||||
|
[about_credits]
|
||||||
|
other = "Original WinAuth by Colin Mackie. Go port maintained by the project authors."
|
||||||
|
|||||||
@@ -367,3 +367,55 @@ other = "二维码扫描失败:%s"
|
|||||||
|
|
||||||
[msg_clipboard_no_image]
|
[msg_clipboard_no_image]
|
||||||
other = "剪贴板中没有图片"
|
other = "剪贴板中没有图片"
|
||||||
|
|
||||||
|
# --- 偏好设置 ---
|
||||||
|
|
||||||
|
[menu_preferences]
|
||||||
|
other = "偏好设置..."
|
||||||
|
|
||||||
|
[dialog_preferences_title]
|
||||||
|
other = "偏好设置"
|
||||||
|
|
||||||
|
[label_theme]
|
||||||
|
other = "主题"
|
||||||
|
|
||||||
|
[theme_system]
|
||||||
|
other = "跟随系统"
|
||||||
|
|
||||||
|
[theme_light]
|
||||||
|
other = "浅色"
|
||||||
|
|
||||||
|
[theme_dark]
|
||||||
|
other = "深色"
|
||||||
|
|
||||||
|
[label_language]
|
||||||
|
other = "语言"
|
||||||
|
|
||||||
|
# --- 关于对话框 ---
|
||||||
|
|
||||||
|
[dialog_about_title]
|
||||||
|
other = "关于 WinAuth"
|
||||||
|
|
||||||
|
[about_app_name]
|
||||||
|
other = "WinAuth (Go 版) — TOTP/HOTP 验证器"
|
||||||
|
|
||||||
|
[about_version_label]
|
||||||
|
other = "版本"
|
||||||
|
|
||||||
|
[about_runtime_label]
|
||||||
|
other = "运行时"
|
||||||
|
|
||||||
|
[about_project_label]
|
||||||
|
other = "项目主页"
|
||||||
|
|
||||||
|
[about_project_url]
|
||||||
|
other = "https://git.wxccs.org/iceking2nd/winauth-go"
|
||||||
|
|
||||||
|
[about_license_label]
|
||||||
|
other = "许可证"
|
||||||
|
|
||||||
|
[about_license_value]
|
||||||
|
other = "MIT"
|
||||||
|
|
||||||
|
[about_credits]
|
||||||
|
other = "原版 WinAuth 由 Colin Mackie 创作。Go 移植由本项目作者维护。"
|
||||||
|
|||||||
+51
-9
@@ -10,12 +10,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gioui.org/app"
|
"gioui.org/app"
|
||||||
"gioui.org/font/gofont"
|
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
"gioui.org/op"
|
"gioui.org/op"
|
||||||
"gioui.org/op/clip"
|
"gioui.org/op/clip"
|
||||||
"gioui.org/op/paint"
|
"gioui.org/op/paint"
|
||||||
"gioui.org/text"
|
|
||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
"gioui.org/widget/material"
|
"gioui.org/widget/material"
|
||||||
@@ -95,6 +93,8 @@ type appState struct {
|
|||||||
hotkeyDialog *hotkeyDialog
|
hotkeyDialog *hotkeyDialog
|
||||||
hotkeyTarget *entry
|
hotkeyTarget *entry
|
||||||
tradesDialog *steamTradesDialog
|
tradesDialog *steamTradesDialog
|
||||||
|
prefsDialog *preferencesDialog
|
||||||
|
aboutDialog *aboutDialog
|
||||||
|
|
||||||
store *store
|
store *store
|
||||||
saveErr string // surfaced in the top bar
|
saveErr string // surfaced in the top bar
|
||||||
@@ -102,6 +102,13 @@ type appState struct {
|
|||||||
hkMgr *win32.HotkeyManager
|
hkMgr *win32.HotkeyManager
|
||||||
|
|
||||||
toast toast
|
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.
|
// 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 {
|
func loop(w *app.Window, configPath string) error {
|
||||||
const fn = "internal.ui.loop"
|
const fn = "internal.ui.loop"
|
||||||
|
|
||||||
th := material.NewTheme()
|
|
||||||
th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
|
|
||||||
|
|
||||||
state := &appState{}
|
state := &appState{}
|
||||||
state.list.Axis = layout.Vertical
|
state.list.Axis = layout.Vertical
|
||||||
|
|
||||||
@@ -146,6 +150,12 @@ func loop(w *app.Window, configPath string) error {
|
|||||||
state.absorbConfig(cfg)
|
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
|
// Spin up the global hotkey manager and register whatever the user
|
||||||
// already had configured. Failures are non-fatal (logged + the row
|
// already had configured. Failures are non-fatal (logged + the row
|
||||||
// just won't fire).
|
// just won't fire).
|
||||||
@@ -170,7 +180,15 @@ func loop(w *app.Window, configPath string) error {
|
|||||||
return e.Err
|
return e.Err
|
||||||
case app.FrameEvent:
|
case app.FrameEvent:
|
||||||
gtx := app.NewContext(&ops, e)
|
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)
|
e.Frame(gtx.Ops)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,8 +338,11 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
|
|||||||
st.setPwDialog = newSetPasswordDialog()
|
st.setPwDialog = newSetPasswordDialog()
|
||||||
case settingsActionImportLegacy:
|
case settingsActionImportLegacy:
|
||||||
st.importDialog = newImportLegacyDialog()
|
st.importDialog = newImportLegacyDialog()
|
||||||
|
case settingsActionPreferences:
|
||||||
|
lang, theme, _, _ := st.store.Preferences()
|
||||||
|
st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme))
|
||||||
case settingsActionAbout:
|
case settingsActionAbout:
|
||||||
// TODO: about dialog (next phase).
|
st.aboutDialog = newAboutDialog()
|
||||||
}
|
}
|
||||||
w.Invalidate()
|
w.Invalidate()
|
||||||
} else {
|
} 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 {
|
if st.importDialog != nil {
|
||||||
return st.importDialog.Layout(gtx, th, func(r importLegacyResult) {
|
return st.importDialog.Layout(gtx, th, func(r importLegacyResult) {
|
||||||
if !r.cancel && r.cfg != nil {
|
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{}
|
return layout.Dimensions{}
|
||||||
}
|
}
|
||||||
lbl := material.Body2(th, msg)
|
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)
|
return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, lbl.Layout)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.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 {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
lbl := material.H6(th, en.Code)
|
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)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"image/color"
|
|
||||||
|
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
@@ -36,16 +34,16 @@ func modalCard(
|
|||||||
cancelBtn *widget.Clickable,
|
cancelBtn *widget.Clickable,
|
||||||
body layout.Widget,
|
body layout.Widget,
|
||||||
) layout.Dimensions {
|
) 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 {
|
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
gtx.Constraints.Max.X = gtx.Dp(420)
|
gtx.Constraints.Max.X = gtx.Dp(420)
|
||||||
return widget.Border{
|
return widget.Border{
|
||||||
Color: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff},
|
Color: activePalette.DialogBorder,
|
||||||
CornerRadius: unit.Dp(4),
|
CornerRadius: unit.Dp(4),
|
||||||
Width: unit.Dp(1),
|
Width: unit.Dp(1),
|
||||||
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
}.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.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(material.H6(th, title).Layout),
|
layout.Rigid(material.H6(th, title).Layout),
|
||||||
@@ -79,16 +77,16 @@ func modalCardCancel(
|
|||||||
cancelBtn *widget.Clickable,
|
cancelBtn *widget.Clickable,
|
||||||
body layout.Widget,
|
body layout.Widget,
|
||||||
) layout.Dimensions {
|
) 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 {
|
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
gtx.Constraints.Max.X = gtx.Dp(420)
|
gtx.Constraints.Max.X = gtx.Dp(420)
|
||||||
return widget.Border{
|
return widget.Border{
|
||||||
Color: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff},
|
Color: activePalette.DialogBorder,
|
||||||
CornerRadius: unit.Dp(4),
|
CornerRadius: unit.Dp(4),
|
||||||
Width: unit.Dp(1),
|
Width: unit.Dp(1),
|
||||||
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
}.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.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(material.H6(th, title).Layout),
|
layout.Rigid(material.H6(th, title).Layout),
|
||||||
@@ -114,7 +112,7 @@ func errorLabel(th *material.Theme, msg string) layout.Widget {
|
|||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
}
|
}
|
||||||
lbl := material.Body2(th, msg)
|
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)
|
return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, lbl.Layout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,7 +2,6 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
|
||||||
|
|
||||||
"gioui.org/app"
|
"gioui.org/app"
|
||||||
"gioui.org/layout"
|
"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 {
|
func drawEmptyPlaceholder(gtx layout.Context, th *material.Theme) layout.Dimensions {
|
||||||
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
lbl := material.Body1(th, i18n.T("msg_empty_list"))
|
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)
|
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))
|
h := gtx.Dp(unit.Dp(1))
|
||||||
size := image.Pt(gtx.Constraints.Max.X, h)
|
size := image.Pt(gtx.Constraints.Max.X, h)
|
||||||
defer clip.Rect{Max: size}.Push(gtx.Ops).Pop()
|
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)
|
paint.PaintOp{}.Add(gtx.Ops)
|
||||||
return layout.Dimensions{Size: size}
|
return layout.Dimensions{Size: size}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"image/color"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
@@ -28,11 +27,11 @@ func entryProgressRing(gtx layout.Context, en *entry) layout.Dimensions {
|
|||||||
remaining := int64(period) - elapsed
|
remaining := int64(period) - elapsed
|
||||||
progress := float32(remaining) / float32(period)
|
progress := float32(remaining) / float32(period)
|
||||||
|
|
||||||
fg := color.NRGBA{R: 0x10, G: 0x70, B: 0xff, A: 0xff}
|
fg := activePalette.RingFg
|
||||||
if remaining <= 5 {
|
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{
|
return progressRing{
|
||||||
Size: unit.Dp(ringDp),
|
Size: unit.Dp(ringDp),
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"image/color"
|
|
||||||
|
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
@@ -18,6 +16,7 @@ const (
|
|||||||
settingsActionNone settingsAction = iota
|
settingsActionNone settingsAction = iota
|
||||||
settingsActionSetPassword
|
settingsActionSetPassword
|
||||||
settingsActionImportLegacy
|
settingsActionImportLegacy
|
||||||
|
settingsActionPreferences
|
||||||
settingsActionAbout
|
settingsActionAbout
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +25,7 @@ const (
|
|||||||
type settingsMenu struct {
|
type settingsMenu struct {
|
||||||
setPwBtn widget.Clickable
|
setPwBtn widget.Clickable
|
||||||
importBtn widget.Clickable
|
importBtn widget.Clickable
|
||||||
|
prefsBtn widget.Clickable
|
||||||
aboutBtn widget.Clickable
|
aboutBtn widget.Clickable
|
||||||
cancelBtn widget.Clickable
|
cancelBtn widget.Clickable
|
||||||
}
|
}
|
||||||
@@ -39,6 +39,8 @@ func (m *settingsMenu) Pick(gtx layout.Context) (settingsAction, bool) {
|
|||||||
return settingsActionSetPassword, true
|
return settingsActionSetPassword, true
|
||||||
case m.importBtn.Clicked(gtx):
|
case m.importBtn.Clicked(gtx):
|
||||||
return settingsActionImportLegacy, true
|
return settingsActionImportLegacy, true
|
||||||
|
case m.prefsBtn.Clicked(gtx):
|
||||||
|
return settingsActionPreferences, true
|
||||||
case m.aboutBtn.Clicked(gtx):
|
case m.aboutBtn.Clicked(gtx):
|
||||||
return settingsActionAbout, true
|
return settingsActionAbout, true
|
||||||
case m.cancelBtn.Clicked(gtx):
|
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 {
|
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 {
|
row := func(btn *widget.Clickable, label string) layout.FlexChild {
|
||||||
return layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
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 {
|
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
gtx.Constraints.Max.X = gtx.Dp(280)
|
gtx.Constraints.Max.X = gtx.Dp(280)
|
||||||
return widget.Border{
|
return widget.Border{
|
||||||
Color: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff},
|
Color: activePalette.DialogBorder,
|
||||||
CornerRadius: unit.Dp(4),
|
CornerRadius: unit.Dp(4),
|
||||||
Width: unit.Dp(1),
|
Width: unit.Dp(1),
|
||||||
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
}.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.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(material.H6(th, i18n.T("menu_settings")).Layout),
|
layout.Rigid(material.H6(th, i18n.T("menu_settings")).Layout),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||||
row(&m.setPwBtn, i18n.T("menu_set_password")),
|
row(&m.setPwBtn, i18n.T("menu_set_password")),
|
||||||
row(&m.importBtn, i18n.T("menu_import_legacy")),
|
row(&m.importBtn, i18n.T("menu_import_legacy")),
|
||||||
|
row(&m.prefsBtn, i18n.T("menu_preferences")),
|
||||||
row(&m.aboutBtn, i18n.T("menu_about")),
|
row(&m.aboutBtn, i18n.T("menu_about")),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
|||||||
+43
-3
@@ -26,6 +26,13 @@ type store struct {
|
|||||||
passphrase []byte
|
passphrase []byte
|
||||||
encrypted bool
|
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 signals a save is pending. The worker reads & resets it.
|
||||||
dirty bool
|
dirty bool
|
||||||
snapshotFn func() []config.Entry
|
snapshotFn func() []config.Entry
|
||||||
@@ -37,6 +44,27 @@ type store struct {
|
|||||||
onError func(error)
|
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.
|
// newStore initializes a store and starts the background save worker.
|
||||||
// snapshotFn is invoked whenever a save runs; it must return a freshly
|
// snapshotFn is invoked whenever a save runs; it must return a freshly
|
||||||
// copied entries slice (the worker holds no UI locks). onError is called
|
// 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.mu.Lock()
|
||||||
s.passphrase = passphrase
|
s.passphrase = passphrase
|
||||||
s.encrypted = cfg.Encrypted
|
s.encrypted = cfg.Encrypted
|
||||||
|
s.language = cfg.Language
|
||||||
|
s.theme = cfg.Theme
|
||||||
|
s.autoLockMinutes = cfg.AutoLockMinutes
|
||||||
|
s.minimizeToTray = cfg.MinimizeToTray
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
logger.WithField("entries", len(cfg.Entries)).Debug("config loaded into store")
|
logger.WithField("entries", len(cfg.Entries)).Debug("config loaded into store")
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
@@ -148,13 +180,21 @@ func (s *store) run() {
|
|||||||
s.dirty = false
|
s.dirty = false
|
||||||
pw := append([]byte(nil), s.passphrase...)
|
pw := append([]byte(nil), s.passphrase...)
|
||||||
enc := s.encrypted
|
enc := s.encrypted
|
||||||
|
lang := s.language
|
||||||
|
theme := s.theme
|
||||||
|
autoLock := s.autoLockMinutes
|
||||||
|
minToTray := s.minimizeToTray
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
entries := s.snapshotFn()
|
entries := s.snapshotFn()
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Version: 1,
|
Version: 1,
|
||||||
Encrypted: enc,
|
Language: lang,
|
||||||
Entries: entries,
|
Theme: theme,
|
||||||
|
AutoLockMinutes: autoLock,
|
||||||
|
MinimizeToTray: minToTray,
|
||||||
|
Encrypted: enc,
|
||||||
|
Entries: entries,
|
||||||
}
|
}
|
||||||
if err := config.SaveYAML(cfg, s.path, pw); err != nil {
|
if err := config.SaveYAML(cfg, s.path, pw); err != nil {
|
||||||
global.Log.WithField("func", fn).WithError(err).Error("save failed")
|
global.Log.WithField("func", fn).WithError(err).Error("save failed")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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 }
|
||||||
@@ -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,7 +2,6 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gioui.org/app"
|
"gioui.org/app"
|
||||||
@@ -48,8 +47,8 @@ func (t *toast) draw(gtx layout.Context, th *material.Theme) {
|
|||||||
if !t.active() {
|
if !t.active() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bg := color.NRGBA{R: 0x20, G: 0x20, B: 0x20, A: 0xe0}
|
bg := activePalette.ToastBg
|
||||||
fg := color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}
|
fg := activePalette.ToastFg
|
||||||
|
|
||||||
macro := op.Record(gtx.Ops)
|
macro := op.Record(gtx.Ops)
|
||||||
pad := layout.UniformInset(unit.Dp(10))
|
pad := layout.UniformInset(unit.Dp(10))
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"image/color"
|
|
||||||
|
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
"gioui.org/widget"
|
"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 {
|
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 {
|
row := func(btn *widget.Clickable, label string) layout.FlexChild {
|
||||||
return layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
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 {
|
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
gtx.Constraints.Max.X = gtx.Dp(320)
|
gtx.Constraints.Max.X = gtx.Dp(320)
|
||||||
return widget.Border{
|
return widget.Border{
|
||||||
Color: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff},
|
Color: activePalette.DialogBorder,
|
||||||
CornerRadius: unit.Dp(4),
|
CornerRadius: unit.Dp(4),
|
||||||
Width: unit.Dp(1),
|
Width: unit.Dp(1),
|
||||||
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
}.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.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(material.H6(th, i18n.T("menu_choose_vendor")).Layout),
|
layout.Rigid(material.H6(th, i18n.T("menu_choose_vendor")).Layout),
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Package version exposes build metadata. Defaults are placeholders for
|
||||||
|
// dev builds; release builds inject real values via -ldflags:
|
||||||
|
//
|
||||||
|
// -X git.wxccs.org/iceking2nd/winauth-go/internal/version.Version=1.2.3
|
||||||
|
// -X git.wxccs.org/iceking2nd/winauth-go/internal/version.Commit=abc1234
|
||||||
|
// -X git.wxccs.org/iceking2nd/winauth-go/internal/version.BuildDate=2026-06-12
|
||||||
|
package version
|
||||||
|
|
||||||
|
// Version is the human-readable release version ("1.2.3" or "dev").
|
||||||
|
var Version = "dev"
|
||||||
|
|
||||||
|
// Commit is the short git SHA the binary was built from. Empty in dev.
|
||||||
|
var Commit = ""
|
||||||
|
|
||||||
|
// BuildDate is the ISO date when the binary was built. Empty in dev.
|
||||||
|
var BuildDate = ""
|
||||||
|
|
||||||
|
// String returns a one-line summary suitable for an About dialog.
|
||||||
|
func String() string {
|
||||||
|
s := Version
|
||||||
|
if Commit != "" {
|
||||||
|
s += " (" + Commit + ")"
|
||||||
|
}
|
||||||
|
if BuildDate != "" {
|
||||||
|
s += " — " + BuildDate
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user