feat(ui): 接入 vendor 真实图标库 + 图标选择器
- assets/icons/ 共 80+ 张真实品牌图标嵌入 UI 包 - entry 新增 IconKey 字段,持久化到 config.Entry.IconName - iconPickerDialog 网格布局图标选择器 - Add 完成后自动弹出图标选择器,Skip 则用 vendor 占位图 - ⋯ 菜单新增 "Change icon..." 项 - vendorIconFor 解析顺序:catalog → vendor 占位 → qr 兜底
@@ -503,3 +503,9 @@ other = "Löschen bestätigen"
|
|||||||
|
|
||||||
[msg_confirm_delete]
|
[msg_confirm_delete]
|
||||||
other = "Eintrag \"%s\" wirklich löschen? Dies kann nicht rückgängig gemacht werden."
|
other = "Eintrag \"%s\" wirklich löschen? Dies kann nicht rückgängig gemacht werden."
|
||||||
|
|
||||||
|
[action_change_icon]
|
||||||
|
other = "Symbol ändern..."
|
||||||
|
|
||||||
|
[dialog_icon_picker_title]
|
||||||
|
other = "Symbol auswählen"
|
||||||
|
|||||||
@@ -505,3 +505,9 @@ other = "Confirm delete"
|
|||||||
|
|
||||||
[msg_confirm_delete]
|
[msg_confirm_delete]
|
||||||
other = "Delete the entry \"%s\"? This cannot be undone."
|
other = "Delete the entry \"%s\"? This cannot be undone."
|
||||||
|
|
||||||
|
[action_change_icon]
|
||||||
|
other = "Change icon..."
|
||||||
|
|
||||||
|
[dialog_icon_picker_title]
|
||||||
|
other = "Choose icon"
|
||||||
|
|||||||
@@ -503,3 +503,9 @@ other = "确认删除"
|
|||||||
|
|
||||||
[msg_confirm_delete]
|
[msg_confirm_delete]
|
||||||
other = "确定删除条目 \"%s\"?此操作不可撤销。"
|
other = "确定删除条目 \"%s\"?此操作不可撤销。"
|
||||||
|
|
||||||
|
[action_change_icon]
|
||||||
|
other = "更换图标..."
|
||||||
|
|
||||||
|
[dialog_icon_picker_title]
|
||||||
|
other = "选择图标"
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ type entry struct {
|
|||||||
Auth authenticator.Authenticator
|
Auth authenticator.Authenticator
|
||||||
Code string
|
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")
|
// Hotkey is the user-configured global shortcut string ("Ctrl+Alt+G")
|
||||||
// or "" if none is set.
|
// or "" if none is set.
|
||||||
Hotkey string
|
Hotkey string
|
||||||
@@ -106,6 +111,7 @@ type appState struct {
|
|||||||
rowMenu *rowActionMenu
|
rowMenu *rowActionMenu
|
||||||
renameDlg *renameDialog
|
renameDlg *renameDialog
|
||||||
confirmDelDlg *confirmDeleteDialog
|
confirmDelDlg *confirmDeleteDialog
|
||||||
|
iconPickerDlg *iconPickerDialog
|
||||||
rowTargetIdx int
|
rowTargetIdx int
|
||||||
|
|
||||||
store *store
|
store *store
|
||||||
@@ -144,7 +150,7 @@ func (st *appState) snapshotEntries() []config.Entry {
|
|||||||
defer st.mu.Unlock()
|
defer st.mu.Unlock()
|
||||||
out := make([]config.Entry, 0, len(st.entries))
|
out := make([]config.Entry, 0, len(st.entries))
|
||||||
for _, en := range 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
|
return out
|
||||||
}
|
}
|
||||||
@@ -285,7 +291,7 @@ func (st *appState) absorbConfig(cfg *config.Config) {
|
|||||||
global.Log.WithField("func", fn).WithError(err).Warn("skip entry")
|
global.Log.WithField("func", fn).WithError(err).Warn("skip entry")
|
||||||
continue
|
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")
|
global.Log.WithField("func", fn).WithError(err).Warn("skip imported entry")
|
||||||
continue
|
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 {
|
if added != nil {
|
||||||
st.mu.Lock()
|
st.mu.Lock()
|
||||||
st.entries = append(st.entries, &entry{Name: name, Auth: added})
|
st.entries = append(st.entries, &entry{Name: name, Auth: added})
|
||||||
|
idx := len(st.entries) - 1
|
||||||
st.mu.Unlock()
|
st.mu.Unlock()
|
||||||
st.store.Push()
|
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
|
st.dialog = nil
|
||||||
w.Invalidate()
|
w.Invalidate()
|
||||||
@@ -656,6 +667,14 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
|
|||||||
st.moveEntry(idx, -1)
|
st.moveEntry(idx, -1)
|
||||||
case rowActionMoveDown:
|
case rowActionMoveDown:
|
||||||
st.moveEntry(idx, +1)
|
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()
|
w.Invalidate()
|
||||||
} else {
|
} else {
|
||||||
@@ -680,6 +699,20 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
|
|||||||
w.Invalidate()
|
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 {
|
if st.tradesDialog != nil {
|
||||||
return st.tradesDialog.Layout(gtx, th)
|
return st.tradesDialog.Layout(gtx, th)
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 973 B |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 157 B |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 783 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 731 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 913 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 326 B |
|
After Width: | Height: | Size: 342 B |
|
After Width: | Height: | Size: 175 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 881 B |
|
After Width: | Height: | Size: 627 B |
|
After Width: | Height: | Size: 1008 B |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 977 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 861 B |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 318 B |
@@ -11,10 +11,11 @@ import (
|
|||||||
// in-memory authenticator plus its display name. The vendor string is
|
// in-memory authenticator plus its display name. The vendor string is
|
||||||
// derived from the authenticator's Name() (which already returns
|
// derived from the authenticator's Name() (which already returns
|
||||||
// "google" / "microsoft" / "okta" / "hotp" / "battlenet" / "steam").
|
// "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{
|
return config.Entry{
|
||||||
Name: name,
|
Name: name,
|
||||||
Vendor: a.Name(),
|
Vendor: a.Name(),
|
||||||
|
IconName: iconKey,
|
||||||
SecretRaw: a.SecretData(),
|
SecretRaw: a.SecretData(),
|
||||||
Hotkey: hotkey,
|
Hotkey: hotkey,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -26,11 +26,16 @@ func entryProgressRing(gtx layout.Context, en *entry) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
vendor := en.Auth.Name()
|
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" {
|
if vendor == "hotp" {
|
||||||
// HOTP has no period to visualise — just show the icon scaled
|
// HOTP has no period to visualise — just show the icon scaled
|
||||||
// to the full slot so rows still align.
|
// to the full slot so rows still align.
|
||||||
return drawCenteredIcon(gtx, vendor, ringDp, ringDp)
|
return drawCenteredIcon(gtx, iconKey, ringDp, ringDp)
|
||||||
}
|
}
|
||||||
|
|
||||||
period := totpPeriod(en.Auth)
|
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.Min = image.Pt(iconPx, iconPx)
|
||||||
iconGtx.Constraints.Max = image.Pt(iconPx, iconPx)
|
iconGtx.Constraints.Max = image.Pt(iconPx, iconPx)
|
||||||
macro := op.Record(gtx.Ops)
|
macro := op.Record(gtx.Ops)
|
||||||
paintIcon(iconGtx, vendor)
|
paintIcon(iconGtx, iconKey)
|
||||||
call := macro.Stop()
|
call := macro.Stop()
|
||||||
offset := image.Pt(
|
offset := image.Pt(
|
||||||
(ringDims.Size.X-iconPx)/2,
|
(ringDims.Size.X-iconPx)/2,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const (
|
|||||||
rowActionDelete
|
rowActionDelete
|
||||||
rowActionMoveUp
|
rowActionMoveUp
|
||||||
rowActionMoveDown
|
rowActionMoveDown
|
||||||
|
rowActionChangeIcon
|
||||||
)
|
)
|
||||||
|
|
||||||
// rowActionMenu is the small popup opened by the ⋯ button on each entry
|
// rowActionMenu is the small popup opened by the ⋯ button on each entry
|
||||||
@@ -33,6 +34,7 @@ type rowActionMenu struct {
|
|||||||
deleteBtn widget.Clickable
|
deleteBtn widget.Clickable
|
||||||
moveUpBtn widget.Clickable
|
moveUpBtn widget.Clickable
|
||||||
moveDownBtn widget.Clickable
|
moveDownBtn widget.Clickable
|
||||||
|
changeIconBtn widget.Clickable
|
||||||
cancelBtn widget.Clickable
|
cancelBtn widget.Clickable
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +63,8 @@ func (m *rowActionMenu) Pick(gtx layout.Context) (rowAction, bool) {
|
|||||||
return rowActionNone, false
|
return rowActionNone, false
|
||||||
}
|
}
|
||||||
return rowActionMoveDown, true
|
return rowActionMoveDown, true
|
||||||
|
case m.changeIconBtn.Clicked(gtx):
|
||||||
|
return rowActionChangeIcon, true
|
||||||
case m.cancelBtn.Clicked(gtx):
|
case m.cancelBtn.Clicked(gtx):
|
||||||
return rowActionNone, true
|
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),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||||
row(&m.renameBtn, i18n.T("action_rename"), true),
|
row(&m.renameBtn, i18n.T("action_rename"), true),
|
||||||
row(&m.deleteBtn, i18n.T("action_delete"), 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.moveUpBtn, i18n.T("action_move_up"), m.canMoveUp),
|
||||||
row(&m.moveDownBtn, i18n.T("action_move_down"), m.canMoveDown),
|
row(&m.moveDownBtn, i18n.T("action_move_down"), m.canMoveDown),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
|||||||
@@ -5,20 +5,88 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"image"
|
"image"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
|
"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 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
|
// vendorIcons holds the decoded vendor PNGs keyed by the authenticator
|
||||||
// Name() return value. Built once at first lookup so we pay the decode
|
// Name() return value. Built once at first lookup.
|
||||||
// cost once per process instead of per frame.
|
|
||||||
var vendorIcons = map[string]image.Image{}
|
var vendorIcons = map[string]image.Image{}
|
||||||
|
|
||||||
func loadVendorIcons() {
|
func ensureVendorIcons() {
|
||||||
const fn = "internal.ui.loadVendorIcons"
|
const fn = "internal.ui.ensureVendorIcons"
|
||||||
|
if len(vendorIcons) > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
files := []string{"google", "microsoft", "okta", "hotp", "battlenet", "steam", "qr"}
|
files := []string{"google", "microsoft", "okta", "hotp", "battlenet", "steam", "qr"}
|
||||||
for _, name := range files {
|
for _, name := range files {
|
||||||
data, err := assetsFS.ReadFile("assets/vendor_icons/" + name + ".png")
|
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
|
// vendorIconFor returns the icon image for the given key. Resolution
|
||||||
// vendor key ("google", "microsoft", ...). Falls back to the generic
|
// order:
|
||||||
// "qr" tile when no specific icon is registered, and nil when even that
|
// 1. iconCatalog (full brand icons from assets/icons/)
|
||||||
// is unavailable (only happens if the embed is broken).
|
// 2. vendorIcons (fallback placeholders from assets/vendor_icons/)
|
||||||
func vendorIconFor(vendor string) image.Image {
|
// 3. vendorIcons["qr"] (generic fallback)
|
||||||
if len(vendorIcons) == 0 {
|
//
|
||||||
loadVendorIcons()
|
// 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 img
|
||||||
}
|
}
|
||||||
return vendorIcons["qr"]
|
return vendorIcons["qr"]
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppIcon returns the decoded application icon, or nil if the asset is
|
// 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
|
// missing.
|
||||||
// window icon, so this exists so callers (e.g. a future tray bucket)
|
|
||||||
// can reuse the same bitmap.
|
|
||||||
func AppIcon() image.Image {
|
func AppIcon() image.Image {
|
||||||
data, err := assetsFS.ReadFile("assets/app_icon.png")
|
data, err := assetsFS.ReadFile("assets/app_icon.png")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||