feat(ui): 条目管理增强 — 重命名/删除/重排序
每行右侧增加 ⋯ 按钮,弹出菜单支持重命名、删除、上移、下移。 - renameEntry / deleteEntry / moveEntry 三个 appState 方法 - rowActionMenu 弹出菜单 + renameDialog + confirmDeleteDialog - 删除自动注销快捷键,所有操作触发配置保存 - 保留顶栏 Add/Remove 全局按钮不变
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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\"?此操作不可撤销。"
|
||||
|
||||
+128
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user