From 352b1d24b882dbaa0fa6a33344cbdeee2ca9859c Mon Sep 17 00:00:00 2001 From: Daniel Wu Date: Fri, 12 Jun 2026 09:19:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E6=9D=A1=E7=9B=AE=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=A2=9E=E5=BC=BA=20=E2=80=94=20=E9=87=8D=E5=91=BD?= =?UTF-8?q?=E5=90=8D/=E5=88=A0=E9=99=A4/=E9=87=8D=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每行右侧增加 ⋯ 按钮,弹出菜单支持重命名、删除、上移、下移。 - renameEntry / deleteEntry / moveEntry 三个 appState 方法 - rowActionMenu 弹出菜单 + renameDialog + confirmDeleteDialog - 删除自动注销快捷键,所有操作触发配置保存 - 保留顶栏 Add/Remove 全局按钮不变 --- internal/i18n/locales/de.toml | 38 ++++++++ internal/i18n/locales/en.toml | 38 ++++++++ internal/i18n/locales/zh-CN.toml | 38 ++++++++ internal/ui/app.go | 129 ++++++++++++++++++++++++++- internal/ui/dialog_confirm_delete.go | 48 ++++++++++ internal/ui/dialog_rename.go | 54 +++++++++++ internal/ui/row_menu.go | 117 ++++++++++++++++++++++++ 7 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 internal/ui/dialog_confirm_delete.go create mode 100644 internal/ui/dialog_rename.go create mode 100644 internal/ui/row_menu.go diff --git a/internal/i18n/locales/de.toml b/internal/i18n/locales/de.toml index a2c9123..00240dd 100644 --- a/internal/i18n/locales/de.toml +++ b/internal/i18n/locales/de.toml @@ -465,3 +465,41 @@ other = "Beim Schließen in den Tray minimieren" [hint_minimize_to_tray] other = "Schließen des Fensters versteckt es im System-Tray statt das Programm zu beenden." + +# --- Eintragsaktionen (umbenennen / löschen / verschieben) --- + +[btn_more] +other = "⋯" + +[btn_delete] +other = "Löschen" + +[dialog_row_actions_title] +other = "Eintragsaktionen" + +[action_rename] +other = "Umbenennen..." + +[action_delete] +other = "Löschen..." + +[action_move_up] +other = "Nach oben" + +[action_move_down] +other = "Nach unten" + +[dialog_rename_title] +other = "Eintrag umbenennen" + +[label_entry_name] +other = "Neuer Name" + +[msg_empty_name] +other = "Name darf nicht leer sein." + +[dialog_confirm_delete_title] +other = "Löschen bestätigen" + +[msg_confirm_delete] +other = "Eintrag \"%s\" wirklich löschen? Dies kann nicht rückgängig gemacht werden." diff --git a/internal/i18n/locales/en.toml b/internal/i18n/locales/en.toml index 2a3e450..14d2bdb 100644 --- a/internal/i18n/locales/en.toml +++ b/internal/i18n/locales/en.toml @@ -467,3 +467,41 @@ other = "Minimize to tray when closing the window" [hint_minimize_to_tray] other = "Closing the window hides it to the system tray instead of exiting." + +# --- Entry actions (rename / delete / reorder) --- + +[btn_more] +other = "⋯" + +[btn_delete] +other = "Delete" + +[dialog_row_actions_title] +other = "Entry actions" + +[action_rename] +other = "Rename..." + +[action_delete] +other = "Delete..." + +[action_move_up] +other = "Move up" + +[action_move_down] +other = "Move down" + +[dialog_rename_title] +other = "Rename entry" + +[label_entry_name] +other = "New name" + +[msg_empty_name] +other = "Name cannot be empty." + +[dialog_confirm_delete_title] +other = "Confirm delete" + +[msg_confirm_delete] +other = "Delete the entry \"%s\"? This cannot be undone." diff --git a/internal/i18n/locales/zh-CN.toml b/internal/i18n/locales/zh-CN.toml index 0e18dae..506424f 100644 --- a/internal/i18n/locales/zh-CN.toml +++ b/internal/i18n/locales/zh-CN.toml @@ -465,3 +465,41 @@ other = "关闭窗口时最小化到托盘" [hint_minimize_to_tray] other = "勾选后点击窗口关闭按钮将隐藏到系统托盘而非退出程序。" + +# --- 条目操作(重命名 / 删除 / 重排序) --- + +[btn_more] +other = "⋯" + +[btn_delete] +other = "删除" + +[dialog_row_actions_title] +other = "条目操作" + +[action_rename] +other = "重命名..." + +[action_delete] +other = "删除..." + +[action_move_up] +other = "上移" + +[action_move_down] +other = "下移" + +[dialog_rename_title] +other = "重命名条目" + +[label_entry_name] +other = "新名称" + +[msg_empty_name] +other = "名称不能为空。" + +[dialog_confirm_delete_title] +other = "确认删除" + +[msg_confirm_delete] +other = "确定删除条目 \"%s\"?此操作不可撤销。" diff --git a/internal/ui/app.go b/internal/ui/app.go index e5c384e..35eee3d 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -73,6 +73,8 @@ type entry struct { hotkeyBtn widget.Clickable // copyBtn copies the current code to the clipboard. copyBtn widget.Clickable + // moreBtn opens the per-row ⋯ popup (rename / delete / move). + moreBtn widget.Clickable } type appState struct { @@ -97,6 +99,15 @@ type appState struct { prefsDialog *preferencesDialog aboutDialog *aboutDialog + // Per-row action popup state. rowMenu owns the visible popup; + // renameDlg / confirmDelDlg are the follow-up modals it spawns. + // rowTargetIdx is the entry index the popup applies to; it is + // re-resolved on each frame in case the entries slice mutated. + rowMenu *rowActionMenu + renameDlg *renameDialog + confirmDelDlg *confirmDeleteDialog + rowTargetIdx int + store *store saveErr string // surfaced in the top bar @@ -298,6 +309,57 @@ func (st *appState) mergeImportedConfig(cfg *config.Config) { } } +// renameEntry sets a new display name for the entry at idx. Empty names +// are rejected by the caller; here we trust the input and only guard +// the index. Pushes a save on success. +func (st *appState) renameEntry(idx int, name string) { + st.mu.Lock() + if idx < 0 || idx >= len(st.entries) { + st.mu.Unlock() + return + } + st.entries[idx].Name = name + st.mu.Unlock() + st.store.Push() +} + +// deleteEntry removes the entry at idx, unregistering its hotkey if +// any. Bounds-checked; out-of-range indexes are a silent no-op so a +// stale popup that survived a concurrent mutation cannot panic. +func (st *appState) deleteEntry(idx int) { + const fn = "internal.ui.appState.deleteEntry" + st.mu.Lock() + if idx < 0 || idx >= len(st.entries) { + st.mu.Unlock() + return + } + en := st.entries[idx] + if en.hotkeyID != 0 && st.hkMgr != nil { + if err := st.hkMgr.Unregister(en.hotkeyID); err != nil { + global.Log.WithField("func", fn).WithError(err). + Warn("unregister hotkey during delete failed") + } + en.hotkeyID = 0 + } + st.entries = append(st.entries[:idx], st.entries[idx+1:]...) + st.mu.Unlock() + st.store.Push() +} + +// moveEntry swaps the entry at idx with its neighbour delta steps away +// (typically ±1). Bounds-checked. Pushes a save on a successful swap. +func (st *appState) moveEntry(idx, delta int) { + st.mu.Lock() + j := idx + delta + if idx < 0 || idx >= len(st.entries) || j < 0 || j >= len(st.entries) { + st.mu.Unlock() + return + } + st.entries[idx], st.entries[j] = st.entries[j], st.entries[idx] + st.mu.Unlock() + st.store.Push() +} + func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Window) layout.Dimensions { // Auto-lock check runs before any input is dispatched so a user who // returns mid-frame still has to type the passphrase before they can @@ -345,7 +407,8 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind var tradesTarget *entry var hotkeyTarget *entry var copyTarget *entry - for _, en := range st.entries { + moreTargetIdx := -1 + for i, en := range st.entries { if en.Auth.Name() == "steam" { if en.tradesBtn.Clicked(gtx) { tradesTarget = en @@ -360,6 +423,10 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind copyTarget = en st.lastActivity = time.Now() } + if en.moreBtn.Clicked(gtx) { + moreTargetIdx = i + st.lastActivity = time.Now() + } if en.Auth.Name() == "hotp" { if en.click.Clicked(gtx) { if code, err := en.Auth.CurrentCode(); err == nil { @@ -393,6 +460,13 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind if copyTarget != nil { st.copyCodeToClipboard(copyTarget, w) } + if moreTargetIdx >= 0 { + st.mu.Lock() + total := len(st.entries) + st.mu.Unlock() + st.rowTargetIdx = moreTargetIdx + st.rowMenu = newRowActionMenu(moreTargetIdx, total) + } // Password retry / first-decrypt loop. if st.pwDialog != nil { @@ -558,6 +632,55 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind }) } + // Per-row action popup. Must be resolved before the entry list + // renders so the popup overlays the main UI without interference. + if st.rowMenu != nil { + action, closed := st.rowMenu.Pick(gtx) + if closed { + idx := st.rowTargetIdx + st.rowMenu = nil + switch action { + case rowActionRename: + st.mu.Lock() + if idx >= 0 && idx < len(st.entries) { + st.renameDlg = newRenameDialog(st.entries[idx].Name) + } + st.mu.Unlock() + case rowActionDelete: + st.mu.Lock() + if idx >= 0 && idx < len(st.entries) { + st.confirmDelDlg = newConfirmDeleteDialog(st.entries[idx].Name) + } + st.mu.Unlock() + case rowActionMoveUp: + st.moveEntry(idx, -1) + case rowActionMoveDown: + st.moveEntry(idx, +1) + } + w.Invalidate() + } else { + return st.rowMenu.Layout(gtx, th) + } + } + if st.renameDlg != nil { + return st.renameDlg.Layout(gtx, th, func(name string, cancel bool) { + if !cancel { + st.renameEntry(st.rowTargetIdx, name) + } + st.renameDlg = nil + w.Invalidate() + }) + } + if st.confirmDelDlg != nil { + return st.confirmDelDlg.Layout(gtx, th, func(confirmed bool) { + if confirmed { + st.deleteEntry(st.rowTargetIdx) + } + st.confirmDelDlg = nil + w.Invalidate() + }) + } + if st.tradesDialog != nil { return st.tradesDialog.Layout(gtx, th) } @@ -647,6 +770,10 @@ func entryRow(gtx layout.Context, th *material.Theme, en *entry) layout.Dimensio return layout.Inset{Right: unit.Dp(8)}.Layout(gtx, material.Button(th, &en.copyBtn, i18n.T("btn_copy")).Layout) }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Inset{Right: unit.Dp(8)}.Layout(gtx, + material.Button(th, &en.moreBtn, i18n.T("btn_more")).Layout) + }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.H6(th, en.Code) lbl.Color = activePalette.RingFg diff --git a/internal/ui/dialog_confirm_delete.go b/internal/ui/dialog_confirm_delete.go new file mode 100644 index 0000000..dfc25ac --- /dev/null +++ b/internal/ui/dialog_confirm_delete.go @@ -0,0 +1,48 @@ +package ui + +import ( + "fmt" + + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + + "git.wxccs.org/iceking2nd/winauth-go/internal/i18n" +) + +// confirmDeleteDialog asks the user to confirm deletion of a specific +// entry. The dialog displays the target name verbatim in the prompt so +// the user can sanity-check what they are about to lose. +type confirmDeleteDialog struct { + name string + okBtn widget.Clickable + cancelBtn widget.Clickable +} + +func newConfirmDeleteDialog(name string) *confirmDeleteDialog { + return &confirmDeleteDialog{name: name} +} + +func (d *confirmDeleteDialog) Layout( + gtx layout.Context, th *material.Theme, + onDone func(confirmed bool), +) layout.Dimensions { + if d.cancelBtn.Clicked(gtx) { + onDone(false) + return layout.Dimensions{Size: gtx.Constraints.Max} + } + if d.okBtn.Clicked(gtx) { + onDone(true) + return layout.Dimensions{Size: gtx.Constraints.Max} + } + + body := func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(material.Body1(th, + fmt.Sprintf(i18n.T("msg_confirm_delete"), d.name)).Layout), + ) + } + return modalCard(gtx, th, i18n.T("dialog_confirm_delete_title"), + i18n.T("btn_delete"), i18n.T("btn_cancel"), + &d.okBtn, &d.cancelBtn, body) +} diff --git a/internal/ui/dialog_rename.go b/internal/ui/dialog_rename.go new file mode 100644 index 0000000..abbf6b9 --- /dev/null +++ b/internal/ui/dialog_rename.go @@ -0,0 +1,54 @@ +package ui + +import ( + "strings" + + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + + "git.wxccs.org/iceking2nd/winauth-go/internal/i18n" +) + +type renameDialog struct { + ed widget.Editor + okBtn widget.Clickable + cancel widget.Clickable + errMsg string +} + +func newRenameDialog(currentName string) *renameDialog { + d := &renameDialog{} + d.ed.SingleLine = true + d.ed.SetText(currentName) + return d +} + +func (d *renameDialog) Layout( + gtx layout.Context, th *material.Theme, + onDone func(string, bool), +) layout.Dimensions { + if d.cancel.Clicked(gtx) { + onDone("", true) + return layout.Dimensions{Size: gtx.Constraints.Max} + } + if d.okBtn.Clicked(gtx) { + name := strings.TrimSpace(d.ed.Text()) + if name == "" { + d.errMsg = i18n.T("msg_empty_name") + } else { + onDone(name, false) + return layout.Dimensions{Size: gtx.Constraints.Max} + } + } + + body := func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(labeledEditor(th, i18n.T("label_entry_name"), &d.ed, "")), + layout.Rigid(errorLabel(th, d.errMsg)), + ) + } + return modalCard(gtx, th, i18n.T("dialog_rename_title"), + i18n.T("btn_ok"), i18n.T("btn_cancel"), + &d.okBtn, &d.cancel, body) +} diff --git a/internal/ui/row_menu.go b/internal/ui/row_menu.go new file mode 100644 index 0000000..91a634c --- /dev/null +++ b/internal/ui/row_menu.go @@ -0,0 +1,117 @@ +package ui + +import ( + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + + "git.wxccs.org/iceking2nd/winauth-go/internal/i18n" +) + +// rowAction is what the per-row ⋯ popup returns to the caller. +type rowAction int + +const ( + rowActionNone rowAction = iota + rowActionRename + rowActionDelete + rowActionMoveUp + rowActionMoveDown +) + +// rowActionMenu is the small popup opened by the ⋯ button on each entry +// row. It carries the target entry index so the caller knows which row +// the chosen action applies to. moveUp/moveDown are disabled (rendered +// but inert) when the index is at a boundary. +type rowActionMenu struct { + idx int + canMoveUp bool + canMoveDown bool + + renameBtn widget.Clickable + deleteBtn widget.Clickable + moveUpBtn widget.Clickable + moveDownBtn widget.Clickable + cancelBtn widget.Clickable +} + +func newRowActionMenu(idx, total int) *rowActionMenu { + return &rowActionMenu{ + idx: idx, + canMoveUp: idx > 0, + canMoveDown: idx < total-1, + } +} + +// Pick reports the user's choice and whether the menu should close. +func (m *rowActionMenu) Pick(gtx layout.Context) (rowAction, bool) { + switch { + case m.renameBtn.Clicked(gtx): + return rowActionRename, true + case m.deleteBtn.Clicked(gtx): + return rowActionDelete, true + case m.moveUpBtn.Clicked(gtx): + if !m.canMoveUp { + return rowActionNone, false + } + return rowActionMoveUp, true + case m.moveDownBtn.Clicked(gtx): + if !m.canMoveDown { + return rowActionNone, false + } + return rowActionMoveDown, true + case m.cancelBtn.Clicked(gtx): + return rowActionNone, true + } + return rowActionNone, false +} + +func (m *rowActionMenu) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions { + fillBackground(gtx, activePalette.ScrimBg) + + row := func(btn *widget.Clickable, label string, enabled bool) layout.FlexChild { + return layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Inset{Top: unit.Dp(4), Bottom: unit.Dp(4)}.Layout(gtx, + func(gtx layout.Context) layout.Dimensions { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + b := material.Button(th, btn, label) + if !enabled { + // Visually dim the disabled state; Pick also + // refuses to dispatch the action so clicks + // are inert beyond the colour change. + b.Background = activePalette.RingBg + b.Color = activePalette.MutedFg + } + return b.Layout(gtx) + }) + }) + } + + return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + gtx.Constraints.Max.X = gtx.Dp(260) + return widget.Border{ + Color: activePalette.DialogBorder, + CornerRadius: unit.Dp(4), + Width: unit.Dp(1), + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + 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("dialog_row_actions_title")).Layout), + 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.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), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(material.Button(th, &m.cancelBtn, i18n.T("btn_cancel")).Layout), + ) + }), + ) + }) + }) + }) +}