feat(ui): 接入 vendor 真实图标库 + 图标选择器

- assets/icons/ 共 80+ 张真实品牌图标嵌入 UI 包
- entry 新增 IconKey 字段,持久化到 config.Entry.IconName
- iconPickerDialog 网格布局图标选择器
- Add 完成后自动弹出图标选择器,Skip 则用 vendor 占位图
- ⋯ 菜单新增 "Change icon..." 项
- vendorIconFor 解析顺序:catalog → vendor 占位 → qr 兜底
This commit is contained in:
2026-06-12 09:41:30 +08:00
parent 352b1d24b8
commit 969600b6ee
95 changed files with 298 additions and 27 deletions
+36 -3
View File
@@ -56,6 +56,11 @@ type entry struct {
Auth authenticator.Authenticator
Code string
// IconKey selects a specific brand icon from the embedded catalog
// (e.g. "GitHubIcon", "DropboxIcon"). Empty means fall back to the
// vendor-name placeholder ("google", "steam", …).
IconKey string
// Hotkey is the user-configured global shortcut string ("Ctrl+Alt+G")
// or "" if none is set.
Hotkey string
@@ -106,6 +111,7 @@ type appState struct {
rowMenu *rowActionMenu
renameDlg *renameDialog
confirmDelDlg *confirmDeleteDialog
iconPickerDlg *iconPickerDialog
rowTargetIdx int
store *store
@@ -144,7 +150,7 @@ func (st *appState) snapshotEntries() []config.Entry {
defer st.mu.Unlock()
out := make([]config.Entry, 0, len(st.entries))
for _, en := range st.entries {
out = append(out, entryFromAuthenticator(en.Name, en.Auth, en.Hotkey))
out = append(out, entryFromAuthenticator(en.Name, en.Auth, en.Hotkey, en.IconKey))
}
return out
}
@@ -285,7 +291,7 @@ func (st *appState) absorbConfig(cfg *config.Config) {
global.Log.WithField("func", fn).WithError(err).Warn("skip entry")
continue
}
st.entries = append(st.entries, &entry{Name: e.Name, Auth: a, Hotkey: e.Hotkey})
st.entries = append(st.entries, &entry{Name: e.Name, Auth: a, Hotkey: e.Hotkey, IconKey: e.IconName})
}
}
@@ -305,7 +311,7 @@ func (st *appState) mergeImportedConfig(cfg *config.Config) {
global.Log.WithField("func", fn).WithError(err).Warn("skip imported entry")
continue
}
st.entries = append(st.entries, &entry{Name: e.Name, Auth: a, Hotkey: e.Hotkey})
st.entries = append(st.entries, &entry{Name: e.Name, Auth: a, Hotkey: e.Hotkey, IconKey: e.IconName})
}
}
@@ -608,8 +614,13 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
if added != nil {
st.mu.Lock()
st.entries = append(st.entries, &entry{Name: name, Auth: added})
idx := len(st.entries) - 1
st.mu.Unlock()
st.store.Push()
// Auto-open icon picker so the user can choose a brand
// icon for the entry right after adding it.
st.rowTargetIdx = idx
st.iconPickerDlg = newIconPickerDialog("")
}
st.dialog = nil
w.Invalidate()
@@ -656,6 +667,14 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
st.moveEntry(idx, -1)
case rowActionMoveDown:
st.moveEntry(idx, +1)
case rowActionChangeIcon:
st.mu.Lock()
current := ""
if idx >= 0 && idx < len(st.entries) {
current = st.entries[idx].IconKey
}
st.mu.Unlock()
st.iconPickerDlg = newIconPickerDialog(current)
}
w.Invalidate()
} else {
@@ -680,6 +699,20 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
w.Invalidate()
})
}
if st.iconPickerDlg != nil {
return st.iconPickerDlg.Layout(gtx, th, func(iconKey string, cancel bool) {
if !cancel {
st.mu.Lock()
if st.rowTargetIdx >= 0 && st.rowTargetIdx < len(st.entries) {
st.entries[st.rowTargetIdx].IconKey = iconKey
}
st.mu.Unlock()
st.store.Push()
}
st.iconPickerDlg = nil
w.Invalidate()
})
}
if st.tradesDialog != nil {
return st.tradesDialog.Layout(gtx, th)
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 977 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

+2 -1
View File
@@ -11,10 +11,11 @@ import (
// in-memory authenticator plus its display name. The vendor string is
// derived from the authenticator's Name() (which already returns
// "google" / "microsoft" / "okta" / "hotp" / "battlenet" / "steam").
func entryFromAuthenticator(name string, a authenticator.Authenticator, hotkey string) config.Entry {
func entryFromAuthenticator(name string, a authenticator.Authenticator, hotkey, iconKey string) config.Entry {
return config.Entry{
Name: name,
Vendor: a.Name(),
IconName: iconKey,
SecretRaw: a.SecretData(),
Hotkey: hotkey,
}
+137
View File
@@ -0,0 +1,137 @@
package ui
import (
"image"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// iconPickerDialog shows the full catalog of brand icons in a scrollable
// grid. The user clicks an icon to select it, then OK to confirm. A
// fixed column count keeps row layout cheap (no FlowWrap needed).
type iconPickerDialog struct {
icons []string
selected string
clickables []widget.Clickable
list widget.List
okBtn widget.Clickable
cancelBtn widget.Clickable
cols int
}
func newIconPickerDialog(currentKey string) *iconPickerDialog {
keys := allIconKeys()
d := &iconPickerDialog{
icons: keys,
selected: currentKey,
clickables: make([]widget.Clickable, len(keys)),
cols: 6,
}
d.list.Axis = layout.Vertical
return d
}
// iconExistsInCatalog reports whether key is in the published icon
// catalog (as opposed to the fallback vendor icons).
func iconExistsInCatalog(key string) bool {
for _, k := range allIconKeys() {
if k == key {
return true
}
}
return false
}
func (d *iconPickerDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(iconKey string, cancel bool),
) layout.Dimensions {
for i := range d.icons {
if d.clickables[i].Clicked(gtx) {
d.selected = d.icons[i]
}
}
if d.cancelBtn.Clicked(gtx) {
onDone("", true)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
if d.okBtn.Clicked(gtx) {
onDone(d.selected, false)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
body := func(gtx layout.Context) layout.Dimensions {
// Cap body height so the picker scrolls instead of overflowing
// the screen on small windows.
maxH := gtx.Dp(unit.Dp(320))
if gtx.Constraints.Max.Y > maxH {
gtx.Constraints.Max.Y = maxH
}
cellDp := unit.Dp(48)
cellPx := gtx.Dp(cellDp)
cols := d.cols
totalRows := (len(d.icons) + cols - 1) / cols
return material.List(th, &d.list).Layout(gtx, totalRows, func(gtx layout.Context, rowIdx int) layout.Dimensions {
start := rowIdx * cols
end := start + cols
if end > len(d.icons) {
end = len(d.icons)
}
rowIcons := d.icons[start:end]
children := make([]layout.FlexChild, len(rowIcons))
for j, key := range rowIcons {
idx := start + j
key := key
sel := key == d.selected
children[j] = layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{
Right: unit.Dp(4), Bottom: unit.Dp(4),
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min = image.Pt(cellPx, cellPx)
gtx.Constraints.Max = image.Pt(cellPx, cellPx)
return d.clickables[idx].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return drawIconCell(gtx, key, sel)
})
})
})
}
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, children...)
})
}
return modalCard(gtx, th, i18n.T("dialog_icon_picker_title"),
i18n.T("btn_ok"), i18n.T("btn_cancel"),
&d.okBtn, &d.cancelBtn, body)
}
// drawIconCell paints one grid cell: optional selection background +
// the icon scaled to fit. Returns the fixed cell-size dimensions.
func drawIconCell(gtx layout.Context, key string, selected bool) layout.Dimensions {
size := gtx.Constraints.Min
if selected {
rect := clip.Rect{Max: size}.Push(gtx.Ops)
paint.ColorOp{Color: activePalette.RingBg}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
rect.Pop()
}
img := vendorIconFor(key)
if img != nil {
w := widget.Image{
Src: paint.NewImageOp(img),
Fit: widget.Contain,
Position: layout.Center,
}
w.Layout(gtx)
}
return layout.Dimensions{Size: size}
}
+7 -2
View File
@@ -26,11 +26,16 @@ func entryProgressRing(gtx layout.Context, en *entry) layout.Dimensions {
}
vendor := en.Auth.Name()
// Prefer the user-chosen brand icon over the vendor placeholder.
iconKey := vendor
if en.IconKey != "" {
iconKey = en.IconKey
}
if vendor == "hotp" {
// HOTP has no period to visualise — just show the icon scaled
// to the full slot so rows still align.
return drawCenteredIcon(gtx, vendor, ringDp, ringDp)
return drawCenteredIcon(gtx, iconKey, ringDp, ringDp)
}
period := totpPeriod(en.Auth)
@@ -64,7 +69,7 @@ func entryProgressRing(gtx layout.Context, en *entry) layout.Dimensions {
iconGtx.Constraints.Min = image.Pt(iconPx, iconPx)
iconGtx.Constraints.Max = image.Pt(iconPx, iconPx)
macro := op.Record(gtx.Ops)
paintIcon(iconGtx, vendor)
paintIcon(iconGtx, iconKey)
call := macro.Stop()
offset := image.Pt(
(ringDims.Size.X-iconPx)/2,
+10 -5
View File
@@ -18,6 +18,7 @@ const (
rowActionDelete
rowActionMoveUp
rowActionMoveDown
rowActionChangeIcon
)
// rowActionMenu is the small popup opened by the ⋯ button on each entry
@@ -29,11 +30,12 @@ type rowActionMenu struct {
canMoveUp bool
canMoveDown bool
renameBtn widget.Clickable
deleteBtn widget.Clickable
moveUpBtn widget.Clickable
moveDownBtn widget.Clickable
cancelBtn widget.Clickable
renameBtn widget.Clickable
deleteBtn widget.Clickable
moveUpBtn widget.Clickable
moveDownBtn widget.Clickable
changeIconBtn widget.Clickable
cancelBtn widget.Clickable
}
func newRowActionMenu(idx, total int) *rowActionMenu {
@@ -61,6 +63,8 @@ func (m *rowActionMenu) Pick(gtx layout.Context) (rowAction, bool) {
return rowActionNone, false
}
return rowActionMoveDown, true
case m.changeIconBtn.Clicked(gtx):
return rowActionChangeIcon, true
case m.cancelBtn.Clicked(gtx):
return rowActionNone, true
}
@@ -102,6 +106,7 @@ func (m *rowActionMenu) Layout(gtx layout.Context, th *material.Theme) layout.Di
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
row(&m.renameBtn, i18n.T("action_rename"), true),
row(&m.deleteBtn, i18n.T("action_delete"), true),
row(&m.changeIconBtn, i18n.T("action_change_icon"), true),
row(&m.moveUpBtn, i18n.T("action_move_up"), m.canMoveUp),
row(&m.moveDownBtn, i18n.T("action_move_down"), m.canMoveDown),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
+88 -16
View File
@@ -5,20 +5,88 @@ import (
"embed"
"image"
_ "image/png"
"sort"
"strings"
"sync"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
//go:embed assets/vendor_icons/*.png assets/app_icon.png
//go:embed assets/vendor_icons/*.png assets/icons/*.png assets/app_icon.png
var assetsFS embed.FS
var (
// iconCatalog holds all decoded brand-icons keyed by stem (filename
// without ".png"). Populated once on first access.
iconCatalog map[string]image.Image
iconCatalogOnce sync.Once
iconSortedKeys []string
)
// loadIconCatalog decodes every PNG from assets/icons/ into iconCatalog.
// Also populates iconSortedKeys for the icon picker.
func loadIconCatalog() {
const fn = "internal.ui.loadIconCatalog"
iconCatalog = map[string]image.Image{}
dir, err := assetsFS.ReadDir("assets/icons")
if err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("read icons dir failed")
return
}
var keys []string
for _, entry := range dir {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".png") {
continue
}
key := strings.TrimSuffix(entry.Name(), ".png")
data, err := assetsFS.ReadFile("assets/icons/" + entry.Name())
if err != nil {
continue
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
global.Log.WithField("func", fn).WithError(err).
WithField("file", entry.Name()).Warn("icon decode failed")
continue
}
iconCatalog[key] = img
keys = append(keys, key)
}
sort.Strings(keys)
iconSortedKeys = keys
global.Log.WithField("func", fn).WithField("count", len(keys)).Debug("icons loaded")
}
// allIconKeys returns the sorted list of available icon keys for the
// icon picker. Safe to call before icons are loaded — triggers load.
func allIconKeys() []string {
iconCatalogOnce.Do(loadIconCatalog)
return iconSortedKeys
}
// iconByKey returns the named icon or nil. Safe to call before icons are
// loaded — triggers load.
func iconByKey(key string) image.Image {
iconCatalogOnce.Do(loadIconCatalog)
return iconCatalog[key]
}
// ---------------------------------------------------------------------------
// Original vendor-icon fallbacks (assets/vendor_icons/). These are simple
// placeholder squares with a single initial — kept so old entries without an
// IconKey still render something recognisable.
// vendorIcons holds the decoded vendor PNGs keyed by the authenticator
// Name() return value. Built once at first lookup so we pay the decode
// cost once per process instead of per frame.
// Name() return value. Built once at first lookup.
var vendorIcons = map[string]image.Image{}
func loadVendorIcons() {
const fn = "internal.ui.loadVendorIcons"
func ensureVendorIcons() {
const fn = "internal.ui.ensureVendorIcons"
if len(vendorIcons) > 0 {
return
}
files := []string{"google", "microsoft", "okta", "hotp", "battlenet", "steam", "qr"}
for _, name := range files {
data, err := assetsFS.ReadFile("assets/vendor_icons/" + name + ".png")
@@ -37,24 +105,28 @@ func loadVendorIcons() {
}
}
// vendorIconFor returns the icon image bound to the given authenticator
// vendor key ("google", "microsoft", ...). Falls back to the generic
// "qr" tile when no specific icon is registered, and nil when even that
// is unavailable (only happens if the embed is broken).
func vendorIconFor(vendor string) image.Image {
if len(vendorIcons) == 0 {
loadVendorIcons()
// vendorIconFor returns the icon image for the given key. Resolution
// order:
// 1. iconCatalog (full brand icons from assets/icons/)
// 2. vendorIcons (fallback placeholders from assets/vendor_icons/)
// 3. vendorIcons["qr"] (generic fallback)
//
// This preserves backward compatibility: callers that pass a vendor name
// ("google", "steam", …) still get their placeholder.
func vendorIconFor(key string) image.Image {
iconCatalogOnce.Do(loadIconCatalog)
ensureVendorIcons()
if img, ok := iconCatalog[key]; ok {
return img
}
if img, ok := vendorIcons[vendor]; ok {
if img, ok := vendorIcons[key]; ok {
return img
}
return vendorIcons["qr"]
}
// AppIcon returns the decoded application icon, or nil if the asset is
// missing. Currently unused at runtime — Gio v0.7 has no API to set a
// window icon, so this exists so callers (e.g. a future tray bucket)
// can reuse the same bitmap.
// missing.
func AppIcon() image.Image {
data, err := assetsFS.ReadFile("assets/app_icon.png")
if err != nil {