Files
winauth-go/internal/ui/dialog_preferences.go
T
iceking2nd c0dd3b9028 feat(ui): 系统托盘 + 关闭最小化到托盘
- 原生 win32 实现的 TrayManager:Shell_NotifyIcon + 私有 message-only
  窗口承载 popup 菜单,专用 OS 线程跑 GetMessage 泵,避免引入 cgo 依赖。
- WindowSubclass 通过 SetWindowLongPtrW 子类化 Gio 主窗口,拦截
  WM_CLOSE:minimize_to_tray=true 时吞掉消息只隐藏窗口;exit 路径下
  放行让 Gio 正常关闭事件循环。
- Preferences 新增"最小化到托盘"复选框,切换后立即重装/卸载钩子,
  不需要重启程序。
- 托盘菜单提供 Show / Hide / Exit;左键单击图标等同 Show;右键弹菜单。
- 非 Windows 平台所有相关 API 退化为 ErrUnsupported / no-op stub。
2026-06-12 04:13:35 +08:00

174 lines
5.7 KiB
Go

package ui
import (
"strconv"
"strings"
"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, theme,
// auto-lock timeout, and minimize-to-tray behaviour.
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
// Auto-lock timeout (whole minutes). 0 disables. Only takes effect
// when the config is encrypted — the prefs dialog does not gate the
// input on that, the store does.
autoLockEd widget.Editor
// Minimize-to-tray: when on, the X button hides the window to the
// system tray instead of exiting. Tray icon stays visible either way.
minToTray widget.Bool
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
autoLockMinutes int
minimizeToTray bool
}
// 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, currentAutoLock int, currentMinToTray bool) *preferencesDialog {
if currentLang == "" {
currentLang = "en"
}
if currentTheme == "" {
currentTheme = themeSystem
}
d := &preferencesDialog{lang: currentLang, theme: currentTheme}
d.autoLockEd.SingleLine = true
d.autoLockEd.SetText(strconv.Itoa(currentAutoLock))
d.minToTray.Value = currentMinToTray
return d
}
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) {
// Best-effort parse — non-numeric or negative input maps to 0
// (disabled) rather than surfacing an error, since the field
// is paired with a "0 disables" hint.
mins, err := strconv.Atoi(strings.TrimSpace(d.autoLockEd.Text()))
if err != nil || mins < 0 {
mins = 0
}
onDone(preferencesResult{
language: d.lang,
theme: d.theme,
autoLockMinutes: mins,
minimizeToTray: d.minToTray.Value,
})
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"),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(labeledEditor(th, i18n.T("label_auto_lock_minutes"), &d.autoLockEd, "")),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(material.Caption(th, i18n.T("hint_auto_lock_disabled")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(material.CheckBox(th, &d.minToTray, i18n.T("label_minimize_to_tray")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(material.Caption(th, i18n.T("hint_minimize_to_tray")).Layout),
)
}
return modalCard(gtx, th, i18n.T("dialog_preferences_title"),
i18n.T("btn_ok"), i18n.T("btn_cancel"),
&d.okBt, &d.cancelBt, body)
}