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"
|
||||
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/logging"
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/ui"
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/version"
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/win32"
|
||||
)
|
||||
|
||||
@@ -53,7 +55,8 @@ func 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)
|
||||
if err != nil {
|
||||
@@ -70,7 +73,12 @@ func main() {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -91,3 +99,19 @@ func main() {
|
||||
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
|
||||
// produced by internal/crypto.EncryptModern and Entries is empty on disk.
|
||||
type Config struct {
|
||||
Version int `yaml:"version" json:"version"`
|
||||
Language string `yaml:"language,omitempty" json:"language,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"`
|
||||
Version int `yaml:"version" json:"version"`
|
||||
Language string `yaml:"language,omitempty" json:"language,omitempty"`
|
||||
// Theme is one of "", "light", "dark", "system" (empty = system).
|
||||
Theme string `yaml:"theme,omitempty" json:"theme,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]
|
||||
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]
|
||||
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]
|
||||
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"
|
||||
|
||||
"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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
"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}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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 (
|
||||
"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))
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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