feat(ui): vendor 图标系统 + 应用图标占位

- 新增 tools/gen_icons 生成 7 张 vendor 占位 PNG + 256x256 应用图标
- internal/ui/vendor_icons.go 用 //go:embed 加载并按 Authenticator.Name() 查表
- entry 行圆环中心叠 16dp vendor 图标,HOTP 直接用图标占位
- 图标为通用色块 + 首字母,无第三方商标;用户可按文件名替换为真实 logo
- assets/README 说明替换方式与 Windows .syso 嵌入步骤
This commit is contained in:
2026-06-12 03:47:00 +08:00
parent b483360778
commit fedc198452
12 changed files with 366 additions and 8 deletions
+70 -8
View File
@@ -1,23 +1,38 @@
package ui
import (
"image"
"time"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
)
// entryProgressRing draws the per-row TOTP countdown ring. For HOTP
// entries it returns a same-sized blank box so the rows still line up.
// entryProgressRing draws the per-row TOTP countdown ring with the
// vendor icon nested in the centre. For HOTP entries the ring is
// omitted (no countdown to show) and the icon fills the same slot so
// rows still line up.
func entryProgressRing(gtx layout.Context, en *entry) layout.Dimensions {
const ringDp = 22
if en.Auth == nil || en.Auth.Name() == "hotp" {
return layout.Dimensions{Size: gtx.Constraints.Constrain(
layout.Spacer{Width: unit.Dp(ringDp), Height: unit.Dp(ringDp)}.Layout(gtx).Size,
)}
const ringDp = 24
const iconDp = 16
if en.Auth == nil {
return layout.Dimensions{Size: image.Pt(gtx.Dp(ringDp), gtx.Dp(ringDp))}
}
vendor := en.Auth.Name()
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)
}
period := totpPeriod(en.Auth)
if period <= 0 {
period = authenticator.DefaultPeriod
@@ -33,13 +48,60 @@ func entryProgressRing(gtx layout.Context, en *entry) layout.Dimensions {
}
bg := activePalette.RingBg
return progressRing{
ringDims := progressRing{
Size: unit.Dp(ringDp),
Stroke: unit.Dp(2.5),
Progress: progress,
Color: fg,
BgColor: bg,
}.Layout(gtx)
// Overlay the vendor icon in the centre of the same slot. We do
// this by recording the icon op into a fixed-size context, then
// offsetting it so its centre matches the ring's centre.
iconPx := gtx.Dp(iconDp)
iconGtx := gtx
iconGtx.Constraints.Min = image.Pt(iconPx, iconPx)
iconGtx.Constraints.Max = image.Pt(iconPx, iconPx)
macro := op.Record(gtx.Ops)
paintIcon(iconGtx, vendor)
call := macro.Stop()
offset := image.Pt(
(ringDims.Size.X-iconPx)/2,
(ringDims.Size.Y-iconPx)/2,
)
st := op.Offset(offset).Push(gtx.Ops)
call.Add(gtx.Ops)
st.Pop()
return ringDims
}
// drawCenteredIcon paints a vendor icon scaled to fit a w×h slot. Used
// for HOTP entries that have no ring around them.
func drawCenteredIcon(gtx layout.Context, vendor string, wDp, hDp unit.Dp) layout.Dimensions {
wPx, hPx := gtx.Dp(wDp), gtx.Dp(hDp)
gtx.Constraints.Min = image.Pt(wPx, hPx)
gtx.Constraints.Max = image.Pt(wPx, hPx)
return paintIcon(gtx, vendor)
}
// paintIcon draws the vendor icon scaled to fit the current
// constraints. Returns its layout dimensions so callers can compose it
// inside a Flex. Returns a zero-sized layout if the icon is missing.
func paintIcon(gtx layout.Context, vendor string) layout.Dimensions {
img := vendorIconFor(vendor)
if img == nil {
return layout.Dimensions{Size: gtx.Constraints.Min}
}
w := widget.Image{
Src: paint.NewImageOp(img),
Fit: widget.Contain,
Position: layout.Center,
}
// Match one source pixel to one device pixel scaled by Fit. We
// don't want device-DPI scaling on top of widget.Fit's own
// scaling, so leave Scale at its 1.0 default.
return w.Layout(gtx)
}
// totpPeriod extracts the configured period from any authenticator whose