diff --git a/main.go b/main.go index 45fd4a9..65280ce 100644 --- a/main.go +++ b/main.go @@ -138,6 +138,7 @@ type ui struct { loadingMessage string statusMessage string errorMessage string + keyboardFocus focusID } var ( @@ -213,6 +214,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession) *ui { u.eyeOffIcon, _ = widget.NewIcon(icons.ActionVisibilityOff) u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy) u.passwordProfile.SetText("strong") + u.keyboardFocus = focusSearch u.filter() return u } @@ -772,7 +774,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { if u.mode == "phone" { gtx.Constraints.Min.X = gtx.Constraints.Max.X } - return outlinedField(gtx, func(gtx layout.Context) layout.Dimensions { + return outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions { editor := material.Editor(u.theme, &u.search, "Search vault") editor.Color = u.theme.Palette.Fg editor.HintColor = mutedColor @@ -876,7 +878,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item ) }) } - if item.ID == u.state.SelectedEntryID { + if item.ID == u.state.SelectedEntryID || u.isFocused(listFocusID(idx)) { return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { size := gtx.Constraints.Min @@ -886,8 +888,14 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item if size.Y == 0 { size.Y = gtx.Constraints.Max.Y } - paint.FillShape(gtx.Ops, selectedColor, clip.Rect{Max: size}.Op()) - paint.FillShape(gtx.Ops, selectedEdge, clip.Rect{Max: image.Pt(4, size.Y)}.Op()) + fillColor := selectedColor + edgeColor := selectedEdge + if u.isFocused(listFocusID(idx)) && item.ID != u.state.SelectedEntryID { + fillColor = color.NRGBA{R: 235, G: 241, B: 238, A: 255} + edgeColor = accentColor + } + paint.FillShape(gtx.Ops, fillColor, clip.Rect{Max: size}.Op()) + paint.FillShape(gtx.Ops, edgeColor, clip.Rect{Max: image.Pt(4, size.Y)}.Op()) return layout.Dimensions{Size: size} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { @@ -1101,8 +1109,7 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { u.filter() } btn := material.Button(u.theme, &u.breadcrumbs[index], label) - btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255} - btn.Color = accentColor + btn.Background, btn.Color = buttonFocusColors(u.isFocused(breadcrumbFocusID(index))) btn.TextSize = unit.Sp(12) btn.Inset = layout.Inset{Top: 6, Bottom: 6, Left: 10, Right: 10} return btn.Layout(gtx) @@ -1221,14 +1228,14 @@ func compactCard(gtx layout.Context, w layout.Widget) layout.Dimensions { }) } -func outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions { - border := color.NRGBA{R: 202, G: 194, B: 180, A: 255} +func outlinedFieldState(gtx layout.Context, focused bool, w layout.Widget) layout.Dimensions { + appearance := fieldFocusAppearance(gtx.Metric, focused) size := gtx.Constraints.Min if size.X == 0 { size.X = gtx.Constraints.Max.X } if size.Y == 0 { - size.Y = gtx.Dp(unit.Dp(44)) + size.Y = appearance.MinHeight } gtx.Constraints.Min = size return layout.Stack{}.Layout(gtx, @@ -1237,10 +1244,13 @@ func outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions { return layout.Dimensions{Size: size} }), layout.Expanded(func(gtx layout.Context) layout.Dimensions { - paint.FillShape(gtx.Ops, border, clip.Rect{Max: image.Pt(size.X, 1)}.Op()) - paint.FillShape(gtx.Ops, border, clip.Rect{Min: image.Pt(0, size.Y-1), Max: image.Pt(size.X, size.Y)}.Op()) - paint.FillShape(gtx.Ops, border, clip.Rect{Max: image.Pt(1, size.Y)}.Op()) - paint.FillShape(gtx.Ops, border, clip.Rect{Min: image.Pt(size.X-1, 0), Max: image.Pt(size.X, size.Y)}.Op()) + return drawFocusOutline(gtx, appearance, size) + }), + layout.Expanded(func(gtx layout.Context) layout.Dimensions { + paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Max: image.Pt(size.X, 1)}.Op()) + paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Min: image.Pt(0, size.Y-1), Max: image.Pt(size.X, size.Y)}.Op()) + paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Max: image.Pt(1, size.Y)}.Op()) + paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Min: image.Pt(size.X-1, 0), Max: image.Pt(size.X, size.Y)}.Op()) return layout.Dimensions{Size: size} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { @@ -1253,8 +1263,8 @@ func outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions { if dims.Size.Y < min.Y { dims.Size.Y = min.Y } - if dims.Size.Y < gtx.Dp(unit.Dp(44)) { - dims.Size.Y = gtx.Dp(unit.Dp(44)) + if dims.Size.Y < appearance.MinHeight { + dims.Size.Y = appearance.MinHeight } return dims }), @@ -1263,8 +1273,7 @@ func outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions { func tonedButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string) layout.Dimensions { btn := material.Button(th, click, label) - btn.Background = color.NRGBA{R: 231, G: 239, B: 235, A: 255} - btn.Color = accentColor + btn.Background, btn.Color = buttonFocusColors(false) btn.CornerRadius = unit.Dp(10) btn.TextSize = unit.Sp(15) return btn.Layout(gtx) diff --git a/main_test.go b/main_test.go index 870a283..38dc303 100644 --- a/main_test.go +++ b/main_test.go @@ -11,6 +11,9 @@ import ( "strings" "testing" + "gioui.org/io/key" + "gioui.org/unit" + "git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/session" "git.julianfamily.org/keepassgo/vault" @@ -758,6 +761,178 @@ func TestUIKeyboardShortcutActionsDispatchExpectedCommands(t *testing.T) { } } +func TestUIKeyboardNavigationMovesAcrossBreadcrumbsListAndDetail(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "bellagio", + Title: "Bellagio", + Username: "rustyryan", + Path: []string{"Root", "Internet"}, + }, + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Path: []string{"Root", "Internet"}, + }, + }, + }) + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + + if got := u.keyboardFocus; got != focusSearch { + t.Fatalf("keyboardFocus = %q, want %q", got, focusSearch) + } + + u.handleKeyPress(key.NameTab, 0) + if got := u.keyboardFocus; got != breadcrumbFocusID(0) { + t.Fatalf("keyboardFocus after Tab = %q, want %q", got, breadcrumbFocusID(0)) + } + + u.handleKeyPress(key.NameTab, 0) + if got := u.keyboardFocus; got != listFocusID(0) { + t.Fatalf("keyboardFocus after second Tab = %q, want %q", got, listFocusID(0)) + } + if got := u.state.SelectedEntryID; got != "bellagio" { + t.Fatalf("SelectedEntryID after list focus = %q, want %q", got, "bellagio") + } + + u.handleKeyPress(key.NameDownArrow, 0) + if got := u.keyboardFocus; got != listFocusID(1) { + t.Fatalf("keyboardFocus after Down = %q, want %q", got, listFocusID(1)) + } + if got := u.state.SelectedEntryID; got != "vault-console" { + t.Fatalf("SelectedEntryID after Down = %q, want %q", got, "vault-console") + } + + u.handleKeyPress(key.NameTab, 0) + if got := u.keyboardFocus; got != detailFocusID(detailFieldTitle) { + t.Fatalf("keyboardFocus after detail Tab = %q, want %q", got, detailFocusID(detailFieldTitle)) + } +} + +func TestUIKeyboardNavigationActivatesBreadcrumbs(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Path: []string{"Root", "Internet"}, + }, + }, + }) + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + u.keyboardFocus = breadcrumbFocusID(0) + + u.handleKeyPress(key.NameRightArrow, 0) + if got := u.keyboardFocus; got != breadcrumbFocusID(1) { + t.Fatalf("keyboardFocus after Right = %q, want %q", got, breadcrumbFocusID(1)) + } + + u.handleKeyPress(key.NameReturn, 0) + if got := u.currentPath; !slices.Equal(got, []string{"Root"}) { + t.Fatalf("currentPath after breadcrumb activation = %v, want [Root]", got) + } +} + +func TestUIKeyboardShortcutsMoveFocusForSearchAndNewEntry(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + }) + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + u.state.SelectedEntryID = "vault-console" + u.loadSelectedEntryIntoEditor() + u.keyboardFocus = listFocusID(0) + + u.handleKeyPress("F", key.ModShortcut) + if got := u.keyboardFocus; got != focusSearch { + t.Fatalf("keyboardFocus after shortcut search = %q, want %q", got, focusSearch) + } + + u.handleKeyPress("N", key.ModShortcut) + if got := u.state.SelectedEntryID; got != "" { + t.Fatalf("SelectedEntryID after shortcut new-entry = %q, want empty", got) + } + if got := u.keyboardFocus; got != detailFocusID(detailFieldTitle) { + t.Fatalf("keyboardFocus after shortcut new-entry = %q, want %q", got, detailFocusID(detailFieldTitle)) + } +} + +func TestUIAccessibilityLabelsDescribeFocusableControls(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Path: []string{"Root", "Internet"}, + }, + }, + }) + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + + if got := u.accessibilityLabel(focusSearch); got != "Search vault" { + t.Fatalf("accessibilityLabel(search) = %q, want %q", got, "Search vault") + } + if got := u.accessibilityLabel(breadcrumbFocusID(1)); got != "Navigate to Root" { + t.Fatalf("accessibilityLabel(breadcrumb) = %q, want %q", got, "Navigate to Root") + } + if got := u.accessibilityLabel(listFocusID(0)); got != "Select entry Vault Console" { + t.Fatalf("accessibilityLabel(list) = %q, want %q", got, "Select entry Vault Console") + } + if got := u.accessibilityLabel(detailFocusID(detailFieldPassword)); got != "Edit Password" { + t.Fatalf("accessibilityLabel(detail password) = %q, want %q", got, "Edit Password") + } +} + +func TestFieldFocusAppearanceScalesForHighDPI(t *testing.T) { + t.Parallel() + + lo := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, true) + hi := fieldFocusAppearance(unit.Metric{PxPerDp: 2.5, PxPerSp: 2.5}, true) + unfocused := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, false) + + if got := lo.MinHeight; got != 44 { + t.Fatalf("fieldFocusAppearance(low).MinHeight = %d, want 44", got) + } + if got := hi.MinHeight; got != 110 { + t.Fatalf("fieldFocusAppearance(high).MinHeight = %d, want 110", got) + } + if got := lo.OutlineWidth; got < 2 { + t.Fatalf("fieldFocusAppearance(low).OutlineWidth = %d, want >= 2", got) + } + if hi.OutlineWidth <= lo.OutlineWidth { + t.Fatalf("fieldFocusAppearance(high).OutlineWidth = %d, want > %d", hi.OutlineWidth, lo.OutlineWidth) + } + if lo.OutlineColor == unfocused.OutlineColor { + t.Fatalf("fieldFocusAppearance().OutlineColor focused = %#v, want distinct from unfocused %#v", lo.OutlineColor, unfocused.OutlineColor) + } +} + func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) { t.Parallel() diff --git a/ui_accessibility.go b/ui_accessibility.go new file mode 100644 index 0000000..5ee2289 --- /dev/null +++ b/ui_accessibility.go @@ -0,0 +1,112 @@ +package main + +import ( + "fmt" + "image" + "image/color" + "strings" + + "gioui.org/layout" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/unit" +) + +type focusAppearance struct { + BorderColor color.NRGBA + OutlineColor color.NRGBA + OutlineWidth int + MinHeight int +} + +func fieldFocusAppearance(metric unit.Metric, focused bool) focusAppearance { + appearance := focusAppearance{ + BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255}, + OutlineColor: color.NRGBA{A: 0}, + OutlineWidth: max(1, metric.Dp(unit.Dp(1))), + MinHeight: metric.Dp(unit.Dp(44)), + } + if focused { + appearance.BorderColor = accentColor + appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72} + appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2))) + } + return appearance +} + +func buttonFocusColors(focused bool) (background color.NRGBA, text color.NRGBA) { + background = color.NRGBA{R: 231, G: 239, B: 235, A: 255} + text = accentColor + if focused { + background = color.NRGBA{R: 214, G: 229, B: 221, A: 255} + } + return background, text +} + +func (u *ui) accessibilityLabel(id focusID) string { + switch { + case id == focusSearch: + return "Search vault" + case strings.HasPrefix(string(id), "breadcrumb:"): + index := focusIndex(id) + crumbs := u.breadcrumbLabels() + if index >= 0 && index < len(crumbs) { + return fmt.Sprintf("Navigate to %s", crumbs[index]) + } + case strings.HasPrefix(string(id), "list:"): + index := focusIndex(id) + if index >= 0 && index < len(u.visible) { + return fmt.Sprintf("Select entry %s", u.visible[index].Title) + } + case strings.HasPrefix(string(id), "detail:"): + name := strings.TrimPrefix(string(id), "detail:") + return fmt.Sprintf("Edit %s", detailFieldLabel(detailField(name))) + } + return "" +} + +func drawFocusOutline(gtx layout.Context, appearance focusAppearance, size image.Point) layout.Dimensions { + if appearance.OutlineColor.A == 0 || appearance.OutlineWidth <= 0 { + return layout.Dimensions{Size: size} + } + + width := appearance.OutlineWidth + paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(size.X, width)}.Op()) + paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(0, size.Y-width), Max: image.Pt(size.X, size.Y)}.Op()) + paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(width, size.Y)}.Op()) + paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(size.X-width, 0), Max: image.Pt(size.X, size.Y)}.Op()) + return layout.Dimensions{Size: size} +} + +func (u *ui) isFocused(id focusID) bool { + return u.keyboardFocus == id +} + +func detailFieldLabel(field detailField) string { + switch field { + case detailFieldID: + return "ID" + case detailFieldTitle: + return "Title" + case detailFieldUsername: + return "Username" + case detailFieldPassword: + return "Password" + case detailFieldURL: + return "URL" + case detailFieldPath: + return "Path" + case detailFieldTags: + return "Tags" + case detailFieldPasswordProfile: + return "Password Profile" + case detailFieldNotes: + return "Notes" + case detailFieldFields: + return "Custom Fields" + case detailFieldHistoryIndex: + return "History Index" + default: + return strings.ReplaceAll(string(field), "-", " ") + } +} diff --git a/ui_forms.go b/ui_forms.go index d0222f9..3ca93e4 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -111,27 +111,27 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions { func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(labeledEditor(u.theme, "ID", &u.entryID, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "ID", &u.entryID, false, u.isFocused(detailFocusID(detailFieldID)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Title", &u.entryTitle, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Title", &u.entryTitle, false, u.isFocused(detailFocusID(detailFieldTitle)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Username", &u.entryUsername, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Username", &u.entryUsername, false, u.isFocused(detailFocusID(detailFieldUsername)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Password", &u.entryPassword, true)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Password", &u.entryPassword, true, u.isFocused(detailFocusID(detailFieldPassword)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "URL", &u.entryURL, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "URL", &u.entryURL, false, u.isFocused(detailFocusID(detailFieldURL)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Path", &u.entryPath, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Path", &u.entryPath, false, u.isFocused(detailFocusID(detailFieldPath)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Tags", &u.entryTags, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Tags", &u.entryTags, false, u.isFocused(detailFocusID(detailFieldTags)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Password Profile", &u.passwordProfile, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Password Profile", &u.passwordProfile, false, u.isFocused(detailFocusID(detailFieldPasswordProfile)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Notes", &u.entryNotes, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Custom Fields (key=value)", &u.entryFields, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Custom Fields (key=value)", &u.entryFields, false, u.isFocused(detailFocusID(detailFieldFields)))), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(labeledEditor(u.theme, "History Index", &u.historyIndex, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { switch u.state.Section { @@ -211,6 +211,16 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { } func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sensitive bool) layout.Widget { + return labeledEditorWithFocus(th, label, editor, sensitive, false) +} + +func labeledEditorWithFocus( + th *material.Theme, + label string, + editor *widget.Editor, + sensitive bool, + focused bool, +) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -219,7 +229,7 @@ func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sens return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return outlinedField(gtx, func(gtx layout.Context) layout.Dimensions { + return outlinedFieldState(gtx, focused, func(gtx layout.Context) layout.Dimensions { mask := editor.Mask if sensitive { editor.Mask = '•' diff --git a/ui_keyboard.go b/ui_keyboard.go new file mode 100644 index 0000000..10ee114 --- /dev/null +++ b/ui_keyboard.go @@ -0,0 +1,361 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + + "gioui.org/io/key" + "git.julianfamily.org/keepassgo/appstate" +) + +type focusID string + +type detailField string + +const ( + focusSearch focusID = "search" + + detailFieldID detailField = "id" + detailFieldTitle detailField = "title" + detailFieldUsername detailField = "username" + detailFieldPassword detailField = "password" + detailFieldURL detailField = "url" + detailFieldPath detailField = "path" + detailFieldTags detailField = "tags" + detailFieldPasswordProfile detailField = "password-profile" + detailFieldNotes detailField = "notes" + detailFieldFields detailField = "fields" + detailFieldHistoryIndex detailField = "history-index" +) + +func breadcrumbFocusID(index int) focusID { + return focusID(fmt.Sprintf("breadcrumb:%d", index)) +} + +func listFocusID(index int) focusID { + return focusID(fmt.Sprintf("list:%d", index)) +} + +func detailFocusID(field detailField) focusID { + return focusID("detail:" + string(field)) +} + +func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool { + if u.handleShortcutKey(name, modifiers) { + return true + } + + switch name { + case key.NameTab: + delta := 1 + if modifiers.Contain(key.ModShift) { + delta = -1 + } + u.moveKeyboardFocus(delta) + return true + case key.NameLeftArrow, key.NameRightArrow, key.NameUpArrow, key.NameDownArrow, key.NameReturn: + return u.handleFocusedKey(name) + default: + return false + } +} + +func (u *ui) moveKeyboardFocus(delta int) { + order := u.focusOrder() + if len(order) == 0 { + return + } + + current := canonicalFocusID(u.keyboardFocus) + index := 0 + for i, item := range order { + if canonicalFocusID(item) == current { + index = i + break + } + } + + index += delta + if index < 0 { + index = len(order) - 1 + } + if index >= len(order) { + index = 0 + } + u.setKeyboardFocus(order[index]) +} + +func (u *ui) focusOrder() []focusID { + order := []focusID{focusSearch} + if u.state.Section != appstate.SectionRecycleBin { + order = append(order, breadcrumbFocusID(0)) + } + if len(u.visible) > 0 { + order = append(order, listFocusID(u.focusedListIndexOrZero())) + } + order = append(order, detailFocusID(u.focusedDetailFieldOrDefault())) + return order +} + +func (u *ui) setKeyboardFocus(id focusID) { + u.keyboardFocus = id + if strings.HasPrefix(string(id), "list:") { + u.focusListIndex(focusIndex(id)) + } +} + +func (u *ui) handleFocusedKey(name key.Name) bool { + switch { + case u.keyboardFocus == focusSearch: + if name == key.NameDownArrow && len(u.visible) > 0 { + u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero())) + return true + } + case strings.HasPrefix(string(u.keyboardFocus), "breadcrumb:"): + return u.handleBreadcrumbKey(name) + case strings.HasPrefix(string(u.keyboardFocus), "list:"): + return u.handleListKey(name) + case strings.HasPrefix(string(u.keyboardFocus), "detail:"): + return u.handleDetailKey(name) + } + return false +} + +func (u *ui) handleBreadcrumbKey(name key.Name) bool { + crumbs := u.breadcrumbLabels() + if len(crumbs) == 0 { + return false + } + + index := focusIndex(u.keyboardFocus) + switch name { + case key.NameLeftArrow: + if index > 0 { + u.keyboardFocus = breadcrumbFocusID(index - 1) + } + return true + case key.NameRightArrow: + if index < len(crumbs)-1 { + u.keyboardFocus = breadcrumbFocusID(index + 1) + } + return true + case key.NameDownArrow: + if len(u.visible) > 0 { + u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero())) + } + return true + case key.NameReturn: + u.activateBreadcrumb(index) + return true + default: + return false + } +} + +func (u *ui) handleListKey(name key.Name) bool { + if len(u.visible) == 0 { + return false + } + + index := focusIndex(u.keyboardFocus) + switch name { + case key.NameUpArrow: + if index > 0 { + u.setKeyboardFocus(listFocusID(index - 1)) + } + return true + case key.NameDownArrow: + if index < len(u.visible)-1 { + u.setKeyboardFocus(listFocusID(index + 1)) + } + return true + case key.NameLeftArrow: + u.keyboardFocus = breadcrumbFocusID(len(u.breadcrumbLabels()) - 1) + return true + case key.NameRightArrow, key.NameReturn: + u.keyboardFocus = detailFocusID(u.focusedDetailFieldOrDefault()) + return true + default: + return false + } +} + +func (u *ui) handleDetailKey(name key.Name) bool { + fields := detailFocusOrder() + index := u.focusedDetailIndex() + + switch name { + case key.NameUpArrow: + if index > 0 { + u.keyboardFocus = detailFocusID(fields[index-1]) + } + return true + case key.NameDownArrow: + if index < len(fields)-1 { + u.keyboardFocus = detailFocusID(fields[index+1]) + } + return true + case key.NameLeftArrow: + if len(u.visible) > 0 { + u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero())) + } + return true + default: + return false + } +} + +func (u *ui) handleShortcutKey(name key.Name, modifiers key.Modifiers) bool { + if !modifiers.Contain(key.ModShortcut) { + return false + } + + switch name { + case "F": + _ = u.performShortcut(shortcutSearch) + case "S": + _ = u.performShortcut(shortcutSave) + case "L": + _ = u.performShortcut(shortcutLock) + case "N": + _ = u.performShortcut(shortcutNewEntry) + case "U": + _ = u.performShortcut(shortcutCopyUser) + case "P": + _ = u.performShortcut(shortcutCopyPassword) + case "O": + _ = u.performShortcut(shortcutCopyURL) + default: + return false + } + + return true +} + +func (u *ui) activateBreadcrumb(index int) { + if index <= 0 { + u.currentPath = nil + } else { + crumbs := u.breadcrumbLabels() + u.currentPath = append([]string{}, crumbs[1:index+1]...) + } + u.filter() + if index >= len(u.breadcrumbLabels()) { + index = len(u.breadcrumbLabels()) - 1 + } + if index < 0 { + index = 0 + } + u.keyboardFocus = breadcrumbFocusID(index) +} + +func (u *ui) breadcrumbLabels() []string { + if u.state.Section == appstate.SectionRecycleBin { + return nil + } + + labels := append([]string{"Vault"}, u.currentPath...) + if u.state.Section == appstate.SectionTemplates { + labels = append([]string{"Templates"}, u.currentPath...) + } + return labels +} + +func (u *ui) focusListIndex(index int) { + if len(u.visible) == 0 { + return + } + if index < 0 { + index = 0 + } + if index >= len(u.visible) { + index = len(u.visible) - 1 + } + + u.keyboardFocus = listFocusID(index) + u.state.SelectedEntryID = u.visible[index].ID + u.loadSelectedEntryIntoEditor() +} + +func (u *ui) focusedListIndexOrZero() int { + if strings.HasPrefix(string(u.keyboardFocus), "list:") { + index := focusIndex(u.keyboardFocus) + if index >= 0 && index < len(u.visible) { + return index + } + } + + for i, item := range u.visible { + if item.ID == u.state.SelectedEntryID { + return i + } + } + + return 0 +} + +func (u *ui) focusedDetailFieldOrDefault() detailField { + if strings.HasPrefix(string(u.keyboardFocus), "detail:") { + name := strings.TrimPrefix(string(u.keyboardFocus), "detail:") + for _, field := range detailFocusOrder() { + if string(field) == name { + return field + } + } + } + + return detailFieldTitle +} + +func (u *ui) focusedDetailIndex() int { + current := u.focusedDetailFieldOrDefault() + for i, field := range detailFocusOrder() { + if field == current { + return i + } + } + return 0 +} + +func detailFocusOrder() []detailField { + return []detailField{ + detailFieldID, + detailFieldTitle, + detailFieldUsername, + detailFieldPassword, + detailFieldURL, + detailFieldPath, + detailFieldTags, + detailFieldPasswordProfile, + detailFieldNotes, + detailFieldFields, + detailFieldHistoryIndex, + } +} + +func canonicalFocusID(id focusID) focusID { + switch { + case strings.HasPrefix(string(id), "breadcrumb:"): + return breadcrumbFocusID(0) + case strings.HasPrefix(string(id), "list:"): + return listFocusID(0) + case strings.HasPrefix(string(id), "detail:"): + return detailFocusID(detailFieldTitle) + default: + return id + } +} + +func focusIndex(id focusID) int { + _, value, ok := strings.Cut(string(id), ":") + if !ok { + return 0 + } + + index, err := strconv.Atoi(value) + if err != nil { + return 0 + } + return index +} diff --git a/ui_shortcuts.go b/ui_shortcuts.go index b3ee142..711e578 100644 --- a/ui_shortcuts.go +++ b/ui_shortcuts.go @@ -24,13 +24,19 @@ func (u *ui) processShortcuts(gtx layout.Context) { event.Op(gtx.Ops, u) for { ev, ok := gtx.Event( - key.Filter{Focus: u, Name: "F", Required: key.ModShortcut}, - key.Filter{Focus: u, Name: "S", Required: key.ModShortcut}, - key.Filter{Focus: u, Name: "L", Required: key.ModShortcut}, - key.Filter{Focus: u, Name: "N", Required: key.ModShortcut}, - key.Filter{Focus: u, Name: "U", Required: key.ModShortcut}, - key.Filter{Focus: u, Name: "P", Required: key.ModShortcut}, - key.Filter{Focus: u, Name: "O", Required: key.ModShortcut}, + key.Filter{Name: "F", Required: key.ModShortcut}, + key.Filter{Name: "S", Required: key.ModShortcut}, + key.Filter{Name: "L", Required: key.ModShortcut}, + key.Filter{Name: "N", Required: key.ModShortcut}, + key.Filter{Name: "U", Required: key.ModShortcut}, + key.Filter{Name: "P", Required: key.ModShortcut}, + key.Filter{Name: "O", Required: key.ModShortcut}, + key.Filter{Name: key.NameTab, Optional: key.ModShift}, + key.Filter{Name: key.NameLeftArrow}, + key.Filter{Name: key.NameRightArrow}, + key.Filter{Name: key.NameUpArrow}, + key.Filter{Name: key.NameDownArrow}, + key.Filter{Name: key.NameReturn}, ) if !ok { break @@ -41,28 +47,14 @@ func (u *ui) processShortcuts(gtx layout.Context) { continue } - switch ke.Name { - case "F": - _ = u.performShortcut(shortcutSearch) - case "S": - _ = u.performShortcut(shortcutSave) - case "L": - _ = u.performShortcut(shortcutLock) - case "N": - _ = u.performShortcut(shortcutNewEntry) - case "U": - _ = u.performShortcut(shortcutCopyUser) - case "P": - _ = u.performShortcut(shortcutCopyPassword) - case "O": - _ = u.performShortcut(shortcutCopyURL) - } + u.handleKeyPress(ke.Name, ke.Modifiers) } } func (u *ui) performShortcut(name string) error { switch name { case shortcutSearch: + u.keyboardFocus = focusSearch return nil case shortcutSave: return u.saveAction() @@ -72,6 +64,7 @@ func (u *ui) performShortcut(name string) error { u.state.SelectedEntryID = "" u.loadSelectedEntryIntoEditor() u.entryPath.SetText(strings.Join(u.currentPath, " / ")) + u.keyboardFocus = detailFocusID(detailFieldTitle) return nil case shortcutCopyUser: return u.copySelectedFieldAction(clipboard.TargetUsername)