443 lines
10 KiB
Go
443 lines
10 KiB
Go
package appui
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gioui.org/io/event"
|
|
"gioui.org/io/key"
|
|
"gioui.org/layout"
|
|
"git.julianfamily.org/keepassgo/internal/appstate"
|
|
editormodel "git.julianfamily.org/keepassgo/internal/appui/editor"
|
|
"git.julianfamily.org/keepassgo/internal/clipboard"
|
|
)
|
|
|
|
type focusID string
|
|
|
|
type detailField = editormodel.Field
|
|
|
|
const (
|
|
focusSearch focusID = "search"
|
|
|
|
detailFieldID = editormodel.FieldID
|
|
detailFieldTitle = editormodel.FieldTitle
|
|
detailFieldUsername = editormodel.FieldUsername
|
|
detailFieldPassword = editormodel.FieldPassword
|
|
detailFieldURL = editormodel.FieldURL
|
|
detailFieldPath = editormodel.FieldPath
|
|
detailFieldTags = editormodel.FieldTags
|
|
detailFieldPasswordProfile = editormodel.FieldPasswordProfile
|
|
detailFieldNotes = editormodel.FieldNotes
|
|
detailFieldFields = editormodel.FieldFields
|
|
detailFieldHistoryIndex = editormodel.FieldHistoryIndex
|
|
)
|
|
|
|
const (
|
|
shortcutSearch = "search"
|
|
shortcutSave = "save"
|
|
shortcutLock = "lock"
|
|
shortcutNewEntry = "new-entry"
|
|
shortcutCopyUser = "copy-user"
|
|
shortcutCopyPassword = "copy-password"
|
|
shortcutCopyURL = "copy-url"
|
|
)
|
|
|
|
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) processShortcuts(gtx layout.Context) {
|
|
event.Op(gtx.Ops, u)
|
|
for {
|
|
ev, ok := gtx.Event(
|
|
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},
|
|
key.Filter{Name: key.NameBack},
|
|
key.Filter{Name: key.NameEscape},
|
|
)
|
|
if !ok {
|
|
break
|
|
}
|
|
|
|
ke, ok := ev.(key.Event)
|
|
if !ok || ke.State != key.Press {
|
|
continue
|
|
}
|
|
|
|
u.handleKeyPress(ke.Name, ke.Modifiers)
|
|
if ke.Name == key.NameBack || ke.Name == key.NameEscape {
|
|
_ = u.handlePhoneBack()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (u *ui) performShortcut(name string) error {
|
|
switch name {
|
|
case shortcutSearch:
|
|
u.keyboardFocus = focusSearch
|
|
return nil
|
|
case shortcutSave:
|
|
return u.saveAction()
|
|
case shortcutLock:
|
|
return u.lockAction()
|
|
case shortcutNewEntry:
|
|
u.state.BeginNewEntry()
|
|
u.loadSelectedEntryIntoEditor()
|
|
u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / "))
|
|
u.keyboardFocus = detailFocusID(detailFieldTitle)
|
|
return nil
|
|
case shortcutCopyUser:
|
|
return u.copySelectedFieldAction(clipboard.TargetUsername)
|
|
case shortcutCopyPassword:
|
|
return u.copySelectedFieldAction(clipboard.TargetPassword)
|
|
case shortcutCopyURL:
|
|
return u.copySelectedFieldAction(clipboard.TargetURL)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool {
|
|
if u.handleShortcutKey(name, modifiers) {
|
|
return true
|
|
}
|
|
if u.isVaultLocked() && name == key.NameReturn {
|
|
u.startUnlockAction()
|
|
return true
|
|
}
|
|
if u.shouldShowLifecycleSetup() && name == key.NameReturn {
|
|
if u.lifecycleMode == "remote" {
|
|
u.startOpenRemoteAction()
|
|
} else {
|
|
u.startOpenVaultAction()
|
|
}
|
|
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 {
|
|
if u.isVaultLocked() {
|
|
return []focusID{detailFocusID(detailFieldPassword)}
|
|
}
|
|
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) {
|
|
var path []string
|
|
if index <= 0 {
|
|
path = nil
|
|
} else {
|
|
crumbs := u.breadcrumbLabels()
|
|
path = append([]string{}, crumbs[1:index+1]...)
|
|
}
|
|
u.state.NavigateToPath(path)
|
|
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.state.CurrentPath...)
|
|
if u.state.Section == appstate.SectionTemplates {
|
|
labels = append([]string{"Templates"}, u.state.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 editormodel.FocusOrder()
|
|
}
|
|
|
|
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
|
|
}
|