Add keyboard-first accessibility behavior
This commit is contained in:
@@ -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 {
|
||||
@@ -1104,8 +1112,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)
|
||||
@@ -1213,14 +1220,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,
|
||||
@@ -1229,10 +1236,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 {
|
||||
@@ -1245,8 +1255,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
|
||||
}),
|
||||
@@ -1255,8 +1265,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)
|
||||
|
||||
+175
@@ -10,6 +10,9 @@ import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"gioui.org/io/key"
|
||||
"gioui.org/unit"
|
||||
|
||||
"git.julianfamily.org/keepassgo/clipboard"
|
||||
"git.julianfamily.org/keepassgo/session"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
@@ -849,6 +852,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()
|
||||
|
||||
|
||||
@@ -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), "-", " ")
|
||||
}
|
||||
}
|
||||
+22
-12
@@ -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 = '•'
|
||||
|
||||
+361
@@ -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
|
||||
}
|
||||
+16
-23
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user