From 2f1cd7876c03eab9c5bbc237d4f6393d941c52d1 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 9 Apr 2026 13:20:12 -0700 Subject: [PATCH] Normalize app UI pane packages --- internal/appui/actions/sync_menu.go | 95 ----- internal/appui/api/model.go | 90 +++++ internal/appui/{ui_api.go => api_views.go} | 97 +---- internal/appui/app.go | 70 ++-- .../{layout/detail => detail/layout}/mode.go | 2 +- internal/appui/detail/model.go | 17 + internal/appui/editor/model.go | 64 ++++ .../appui/{ui_editor.go => entry_editor.go} | 0 internal/appui/{ui_frame.go => frame.go} | 0 internal/appui/header.go | 176 +++++++++ .../header => header/layout}/dropdown.go | 2 +- internal/appui/header_main_menu.go | 64 ++++ internal/appui/header_menu_layout.go | 52 +++ ...i_layout_header.go => header_sync_menu.go} | 336 +++--------------- internal/appui/{ui_keyboard.go => input.go} | 114 ++++-- internal/appui/lifecycle/model.go | 9 + ...ions_lifecycle.go => lifecycle_actions.go} | 0 .../appui/{ui_forms.go => lifecycle_forms.go} | 17 + .../{layout/list => list/layout}/sections.go | 2 +- internal/appui/list/model.go | 8 + internal/appui/main_test.go | 4 +- .../{ui_recent_state.go => recent_state.go} | 0 internal/appui/{ui_runtime.go => runtime.go} | 0 .../appui/{ui_preferences.go => settings.go} | 149 ++++++-- internal/appui/settings/model.go | 50 +++ internal/appui/sync/model.go | 119 +++++++ .../{ui_sync_dialog.go => sync_dialog.go} | 0 internal/appui/ui_accessibility.go | 135 ------- internal/appui/ui_branding.go | 44 --- internal/appui/ui_layout_lifecycle.go | 24 -- internal/appui/ui_shortcuts.go | 83 ----- internal/appui/ui_sync_menu_actions.go | 51 --- 32 files changed, 961 insertions(+), 913 deletions(-) delete mode 100644 internal/appui/actions/sync_menu.go create mode 100644 internal/appui/api/model.go rename internal/appui/{ui_api.go => api_views.go} (93%) rename internal/appui/{layout/detail => detail/layout}/mode.go (96%) create mode 100644 internal/appui/detail/model.go create mode 100644 internal/appui/editor/model.go rename internal/appui/{ui_editor.go => entry_editor.go} (100%) rename internal/appui/{ui_frame.go => frame.go} (100%) create mode 100644 internal/appui/header.go rename internal/appui/{layout/header => header/layout}/dropdown.go (99%) create mode 100644 internal/appui/header_main_menu.go create mode 100644 internal/appui/header_menu_layout.go rename internal/appui/{ui_layout_header.go => header_sync_menu.go} (50%) rename internal/appui/{ui_keyboard.go => input.go} (72%) create mode 100644 internal/appui/lifecycle/model.go rename internal/appui/{ui_actions_lifecycle.go => lifecycle_actions.go} (100%) rename internal/appui/{ui_forms.go => lifecycle_forms.go} (99%) rename internal/appui/{layout/list => list/layout}/sections.go (94%) create mode 100644 internal/appui/list/model.go rename internal/appui/{ui_recent_state.go => recent_state.go} (100%) rename internal/appui/{ui_runtime.go => runtime.go} (100%) rename internal/appui/{ui_preferences.go => settings.go} (65%) create mode 100644 internal/appui/settings/model.go create mode 100644 internal/appui/sync/model.go rename internal/appui/{ui_sync_dialog.go => sync_dialog.go} (100%) delete mode 100644 internal/appui/ui_accessibility.go delete mode 100644 internal/appui/ui_branding.go delete mode 100644 internal/appui/ui_layout_lifecycle.go delete mode 100644 internal/appui/ui_shortcuts.go delete mode 100644 internal/appui/ui_sync_menu_actions.go diff --git a/internal/appui/actions/sync_menu.go b/internal/appui/actions/sync_menu.go deleted file mode 100644 index 15a56e0..0000000 --- a/internal/appui/actions/sync_menu.go +++ /dev/null @@ -1,95 +0,0 @@ -package actions - -import "git.julianfamily.org/keepassgo/internal/appstate" - -type SyncMenuModel struct { - HasOpenVault bool - HasSelectedBinding bool - ShowSelectors bool - ShowShare bool - ShowSaveCurrentBinding bool - SavedBindingSummary SyncMenuBindingSummary - RemoteBaseURL string - RemotePath string - RemoteUsername string - RemotePassword string - SelectedVaultSyncMode appstate.SyncMode -} - -type SyncMenuBindingSummary struct { - ProfileLabel string - CredentialLabel string - SyncLabel string - OK bool -} - -func (m SyncMenuModel) SavedBindingHeading() string { - if !m.ShowSelectors { - return "Use this vault's saved remote sync target" - } - return "Use a saved remote profile from this vault" -} - -func (m SyncMenuModel) OpenSelectedButtonLabel() string { - if !m.ShowSelectors { - return "Use Remote Sync" - } - return "Open Saved Remote" -} - -func (m SyncMenuModel) ShowDirectRemoteSyncShortcut() bool { - return m.HasOpenVault && m.HasSelectedBinding -} - -func (m SyncMenuModel) DirectRemoteSyncShortcutLabel() string { - return "Use Remote Sync" -} - -func (m SyncMenuModel) ShowRemoteSyncSettingsShortcut() bool { - return m.HasOpenVault && m.HasSelectedBinding -} - -func (m SyncMenuModel) RemoteSyncSettingsShortcutLabel() string { - return "Remote Sync Settings" -} - -func (m SyncMenuModel) ShowRemoveRemoteSyncShortcut() bool { - return m.ShowRemoteSyncSettingsShortcut() -} - -func (m SyncMenuModel) RemoveRemoteSyncShortcutLabel() string { - return "Stop Using Remote Sync" -} - -func (m SyncMenuModel) ShowRemoteSyncSetupShortcut() bool { - return m.HasOpenVault && !m.HasSelectedBinding -} - -func (m SyncMenuModel) RemoteSyncSetupShortcutLabel() string { - return "Set Up Remote Sync" -} - -func (m SyncMenuModel) ActionLabels() []string { - labels := []string{"Open Advanced Sync"} - if m.ShowRemoteSyncSetupShortcut() { - labels = append(labels, m.RemoteSyncSetupShortcutLabel()) - } - if m.ShowDirectRemoteSyncShortcut() { - labels = append(labels, m.DirectRemoteSyncShortcutLabel()) - } - if m.ShowRemoteSyncSettingsShortcut() { - labels = append(labels, m.RemoteSyncSettingsShortcutLabel()) - } - if m.ShowRemoveRemoteSyncShortcut() { - labels = append(labels, m.RemoveRemoteSyncShortcutLabel()) - } - return labels -} - -func (m SyncMenuModel) SaveCurrentRemoteBindingHeading() string { - return "Bind this local vault to the current remote target" -} - -func (m SyncMenuModel) SaveCurrentRemoteBindingButtonLabel() string { - return "Save Remote In Vault" -} diff --git a/internal/appui/api/model.go b/internal/appui/api/model.go new file mode 100644 index 0000000..3ccb55e --- /dev/null +++ b/internal/appui/api/model.go @@ -0,0 +1,90 @@ +package api + +import ( + "strings" + + "git.julianfamily.org/keepassgo/internal/apiaudit" + "git.julianfamily.org/keepassgo/internal/apitokens" +) + +type AuditQuickFilter struct { + Label string + Query string +} + +func Operations() []apitokens.Operation { + return []apitokens.Operation{ + apitokens.OperationListEntries, + apitokens.OperationListGroups, + apitokens.OperationReadEntry, + apitokens.OperationCopyPassword, + apitokens.OperationCopyUsername, + apitokens.OperationCopyURL, + apitokens.OperationMutateEntry, + apitokens.OperationMutateGroup, + apitokens.OperationManageVault, + } +} + +func AuditDecisionLabel(eventType apiaudit.EventType) string { + switch eventType { + case apiaudit.EventApprovalRequested: + return "Requested" + case apiaudit.EventApprovalAllowed: + return "Allowed" + case apiaudit.EventApprovalDenied: + return "Denied" + case apiaudit.EventApprovalCanceled: + return "Canceled" + case apiaudit.EventApprovalTimedOut: + return "Timed Out" + case apiaudit.EventAuthRejected: + return "Auth Rejected" + default: + return strings.ReplaceAll(string(eventType), "_", " ") + } +} + +func AuditOperationLabel(operation apitokens.Operation) string { + if strings.TrimSpace(string(operation)) == "" { + return "Other" + } + return strings.ReplaceAll(string(operation), "_", " ") +} + +func CompactAuditFilterLabel(label string) string { + label = strings.TrimSpace(label) + if len(label) <= 22 { + return label + } + return label[:19] + "..." +} + +func AuditEventSearchTerms(event apiaudit.Event) string { + parts := []string{ + string(event.Type), + AuditDecisionLabel(event.Type), + event.TokenName, + event.ClientName, + string(event.Operation), + AuditOperationLabel(event.Operation), + strings.Join(event.Resource.Path, " / "), + event.Resource.EntryID, + event.Message, + } + switch event.Type { + case apiaudit.EventApprovalAllowed: + parts = append(parts, "allow approved") + case apiaudit.EventApprovalDenied: + parts = append(parts, "deny denied") + case apiaudit.EventApprovalRequested: + parts = append(parts, "prompt requested") + case apiaudit.EventApprovalCanceled: + parts = append(parts, "cancel canceled") + case apiaudit.EventApprovalTimedOut: + parts = append(parts, "timeout timed out") + case apiaudit.EventAuthRejected: + parts = append(parts, "rejected unauthorized") + } + return strings.ToLower(strings.Join(parts, " ")) +} diff --git a/internal/appui/ui_api.go b/internal/appui/api_views.go similarity index 93% rename from internal/appui/ui_api.go rename to internal/appui/api_views.go index e2c015d..fc8c876 100644 --- a/internal/appui/ui_api.go +++ b/internal/appui/api_views.go @@ -12,89 +12,10 @@ import ( "gioui.org/widget/material" "git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apitokens" + apiui "git.julianfamily.org/keepassgo/internal/appui/api" ) -func apiOperations() []apitokens.Operation { - return []apitokens.Operation{ - apitokens.OperationListEntries, - apitokens.OperationListGroups, - apitokens.OperationReadEntry, - apitokens.OperationCopyPassword, - apitokens.OperationCopyUsername, - apitokens.OperationCopyURL, - apitokens.OperationMutateEntry, - apitokens.OperationMutateGroup, - apitokens.OperationManageVault, - } -} - -type apiAuditQuickFilter struct { - Label string - Query string -} - -func apiAuditDecisionLabel(eventType apiaudit.EventType) string { - switch eventType { - case apiaudit.EventApprovalRequested: - return "Requested" - case apiaudit.EventApprovalAllowed: - return "Allowed" - case apiaudit.EventApprovalDenied: - return "Denied" - case apiaudit.EventApprovalCanceled: - return "Canceled" - case apiaudit.EventApprovalTimedOut: - return "Timed Out" - case apiaudit.EventAuthRejected: - return "Auth Rejected" - default: - return strings.ReplaceAll(string(eventType), "_", " ") - } -} - -func apiAuditOperationLabel(operation apitokens.Operation) string { - if strings.TrimSpace(string(operation)) == "" { - return "Other" - } - return strings.ReplaceAll(string(operation), "_", " ") -} - -func compactAuditFilterLabel(label string) string { - label = strings.TrimSpace(label) - if len(label) <= 22 { - return label - } - return label[:19] + "..." -} - -func apiAuditEventSearchTerms(event apiaudit.Event) string { - parts := []string{ - string(event.Type), - apiAuditDecisionLabel(event.Type), - event.TokenName, - event.ClientName, - string(event.Operation), - apiAuditOperationLabel(event.Operation), - strings.Join(event.Resource.Path, " / "), - event.Resource.EntryID, - event.Message, - } - switch event.Type { - case apiaudit.EventApprovalAllowed: - parts = append(parts, "allow approved") - case apiaudit.EventApprovalDenied: - parts = append(parts, "deny denied") - case apiaudit.EventApprovalRequested: - parts = append(parts, "prompt requested") - case apiaudit.EventApprovalCanceled: - parts = append(parts, "cancel canceled") - case apiaudit.EventApprovalTimedOut: - parts = append(parts, "timeout timed out") - case apiaudit.EventAuthRejected: - parts = append(parts, "rejected unauthorized") - } - return strings.ToLower(strings.Join(parts, " ")) -} +type apiAuditQuickFilter = apiui.AuditQuickFilter func apiAuditFilterButtons(clicks *[]widget.Clickable, filters []apiAuditQuickFilter) []widget.Clickable { if len(filters) == 0 { @@ -126,7 +47,7 @@ func (u *ui) apiAuditQuickFilters(events []apiaudit.Event) ([]apiAuditQuickFilte } if _, ok := decisionSeen[event.Type]; !ok { decisionSeen[event.Type] = struct{}{} - label := apiAuditDecisionLabel(event.Type) + label := apiui.AuditDecisionLabel(event.Type) decisions = append(decisions, apiAuditQuickFilter{Label: label, Query: label}) } if strings.TrimSpace(string(event.Operation)) == "" { @@ -136,7 +57,7 @@ func (u *ui) apiAuditQuickFilters(events []apiaudit.Event) ([]apiAuditQuickFilte continue } operationSeen[event.Operation] = struct{}{} - label := apiAuditOperationLabel(event.Operation) + label := apiui.AuditOperationLabel(event.Operation) operations = append(operations, apiAuditQuickFilter{Label: label, Query: label}) } @@ -321,7 +242,7 @@ func parseAPITokenExpiry(text string) (*time.Time, error) { func parseAPIPolicyOperation(text string) (apitokens.Operation, error) { value := apitokens.Operation(strings.TrimSpace(text)) - for _, operation := range apiOperations() { + for _, operation := range apiui.Operations() { if operation == value { return value, nil } @@ -450,7 +371,7 @@ func (u *ui) apiAuditEvents() []apiaudit.Event { } filtered := make([]apiaudit.Event, 0, len(events)) for _, event := range events { - haystack := apiAuditEventSearchTerms(event) + haystack := apiui.AuditEventSearchTerms(event) if strings.Contains(haystack, query) { filtered = append(filtered, event) } @@ -785,7 +706,7 @@ func (u *ui) apiAuditQuickFilterRow(gtx layout.Context, title string, filters [] click := &buttons[i] selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query)) column = append(column, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query) + return u.auditQuickFilterButton(gtx, click, apiui.CompactAuditFilterLabel(filter.Label), selected, filter.Query) })) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, column...) @@ -799,7 +720,7 @@ func (u *ui) apiAuditQuickFilterRow(gtx layout.Context, title string, filters [] click := &buttons[i] selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query)) flexChildren = append(flexChildren, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query) + return u.auditQuickFilterButton(gtx, click, apiui.CompactAuditFilterLabel(filter.Label), selected, filter.Query) })) } return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, flexChildren...) @@ -1051,7 +972,7 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions { return material.CheckBox(u.theme, &u.apiPolicyGroupScopeW, "Group scope (unchecked means exact entry scope)").Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false)), + layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiui.Operations()), ", "), &u.apiPolicyOperation, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.apiPolicyGroupScopeW.Value { diff --git a/internal/appui/app.go b/internal/appui/app.go index 9a438b5..d1217ad 100644 --- a/internal/appui/app.go +++ b/internal/appui/app.go @@ -26,9 +26,13 @@ import ( "git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/appstate" - detaillayout "git.julianfamily.org/keepassgo/internal/appui/layout/detail" - listlayout "git.julianfamily.org/keepassgo/internal/appui/layout/list" + detailmodel "git.julianfamily.org/keepassgo/internal/appui/detail" + detaillayout "git.julianfamily.org/keepassgo/internal/appui/detail/layout" + lifecyclemodel "git.julianfamily.org/keepassgo/internal/appui/lifecycle" + listmodel "git.julianfamily.org/keepassgo/internal/appui/list" + listlayout "git.julianfamily.org/keepassgo/internal/appui/list/layout" "git.julianfamily.org/keepassgo/internal/appui/platform" + syncmodel "git.julianfamily.org/keepassgo/internal/appui/sync" keepassassets "git.julianfamily.org/keepassgo/internal/assets" "git.julianfamily.org/keepassgo/internal/clipboard" "git.julianfamily.org/keepassgo/internal/passwords" @@ -105,24 +109,17 @@ type uiSurface struct { Locked bool } -type lifecycleOpenIntent string +type lifecycleOpenIntent = lifecyclemodel.OpenIntent const ( - lifecycleOpenIntentNone lifecycleOpenIntent = "" - lifecycleOpenIntentRemoteSyncSetup lifecycleOpenIntent = "remote_sync_setup" - lifecycleOpenIntentRemoteSyncSettings lifecycleOpenIntent = "remote_sync_settings" + lifecycleOpenIntentNone = lifecyclemodel.OpenIntentNone + lifecycleOpenIntentRemoteSyncSetup = lifecyclemodel.OpenIntentRemoteSyncSetup + lifecycleOpenIntentRemoteSyncSettings = lifecyclemodel.OpenIntentRemoteSyncSettings ) -type emptyState struct { - Title string - Body string -} +type emptyState = detailmodel.EmptyState -type vaultSummary struct { - Title string - Detail string - Context string -} +type vaultSummary = detailmodel.VaultSummary type sessionStatus interface { HasVault() bool @@ -130,10 +127,7 @@ type sessionStatus interface { IsRemote() bool } -type attachmentItem struct { - Name string - Size int -} +type attachmentItem = detailmodel.AttachmentItem type statePaths struct { DefaultSaveAsPath string @@ -195,32 +189,27 @@ const ( autofillFirstFillApprovalBlock autofillFirstFillApprovalMode = "block" ) -type entriesSectionState struct { - Path []string - SearchQuery string - SelectedEntryID string - Editing bool -} +type entriesSectionState = listmodel.EntriesSectionState -type syncSourceMode string +type syncSourceMode = syncmodel.SourceMode const ( - syncSourceLocal syncSourceMode = "local" - syncSourceRemote syncSourceMode = "remote" + syncSourceLocal = syncmodel.SourceLocal + syncSourceRemote = syncmodel.SourceRemote ) -type syncDirection string +type syncDirection = syncmodel.Direction const ( - syncDirectionPull syncDirection = "pull" - syncDirectionPush syncDirection = "push" + syncDirectionPull = syncmodel.DirectionPull + syncDirectionPush = syncmodel.DirectionPush ) -type syncDialogPurpose string +type syncDialogPurpose = syncmodel.DialogPurpose const ( - syncDialogPurposeAdvanced syncDialogPurpose = "advanced" - syncDialogPurposeRemoteSetup syncDialogPurpose = "remote-setup" + syncDialogPurposeAdvanced = syncmodel.DialogPurposeAdvanced + syncDialogPurposeRemoteSetup = syncmodel.DialogPurposeRemoteSetup ) type ui struct { @@ -3106,18 +3095,7 @@ func syncDialogSectionLabel(th *material.Theme, text string) layout.Widget { } func syncDialogSummaryText(purpose syncDialogPurpose, source syncSourceMode, direction syncDirection) string { - if purpose == syncDialogPurposeRemoteSetup { - return "Push this local vault to a WebDAV target and save that target for future sync." - } - sourceLabel := "another local vault file" - if source == syncSourceRemote { - sourceLabel = "another WebDAV-backed vault" - } - action := "Pull changes from" - if direction == syncDirectionPush { - action = "Push the current vault into" - } - return action + " " + sourceLabel + "." + return syncmodel.SummaryText(purpose, source, direction) } func syncDialogSummaryCard(gtx layout.Context, th *material.Theme, purpose syncDialogPurpose, source syncSourceMode, direction syncDirection) layout.Dimensions { diff --git a/internal/appui/layout/detail/mode.go b/internal/appui/detail/layout/mode.go similarity index 96% rename from internal/appui/layout/detail/mode.go rename to internal/appui/detail/layout/mode.go index b19ea51..ed22688 100644 --- a/internal/appui/layout/detail/mode.go +++ b/internal/appui/detail/layout/mode.go @@ -1,4 +1,4 @@ -package detail +package layout type Mode string diff --git a/internal/appui/detail/model.go b/internal/appui/detail/model.go new file mode 100644 index 0000000..1a1736f --- /dev/null +++ b/internal/appui/detail/model.go @@ -0,0 +1,17 @@ +package detail + +type EmptyState struct { + Title string + Body string +} + +type VaultSummary struct { + Title string + Detail string + Context string +} + +type AttachmentItem struct { + Name string + Size int +} diff --git a/internal/appui/editor/model.go b/internal/appui/editor/model.go new file mode 100644 index 0000000..a21d1ed --- /dev/null +++ b/internal/appui/editor/model.go @@ -0,0 +1,64 @@ +package editor + +import "strings" + +type Field string + +const ( + FieldID Field = "id" + FieldTitle Field = "title" + FieldUsername Field = "username" + FieldPassword Field = "password" + FieldURL Field = "url" + FieldPath Field = "path" + FieldTags Field = "tags" + FieldPasswordProfile Field = "password-profile" + FieldNotes Field = "notes" + FieldFields Field = "fields" + FieldHistoryIndex Field = "history-index" +) + +func Label(field Field) string { + switch field { + case FieldID: + return "ID" + case FieldTitle: + return "Title" + case FieldUsername: + return "Username" + case FieldPassword: + return "Password" + case FieldURL: + return "URL" + case FieldPath: + return "Path" + case FieldTags: + return "Tags" + case FieldPasswordProfile: + return "Password Profile" + case FieldNotes: + return "Notes" + case FieldFields: + return "Custom Fields" + case FieldHistoryIndex: + return "History Index" + default: + return strings.ReplaceAll(string(field), "-", " ") + } +} + +func FocusOrder() []Field { + return []Field{ + FieldID, + FieldTitle, + FieldUsername, + FieldPassword, + FieldURL, + FieldPath, + FieldTags, + FieldPasswordProfile, + FieldNotes, + FieldFields, + FieldHistoryIndex, + } +} diff --git a/internal/appui/ui_editor.go b/internal/appui/entry_editor.go similarity index 100% rename from internal/appui/ui_editor.go rename to internal/appui/entry_editor.go diff --git a/internal/appui/ui_frame.go b/internal/appui/frame.go similarity index 100% rename from internal/appui/ui_frame.go rename to internal/appui/frame.go diff --git a/internal/appui/header.go b/internal/appui/header.go new file mode 100644 index 0000000..d937235 --- /dev/null +++ b/internal/appui/header.go @@ -0,0 +1,176 @@ +package appui + +import ( + "image" + + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/paint" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout" +) + +func (u *ui) header(gtx layout.Context) layout.Dimensions { + if u.usesCompactViewport() { + if u.shouldShowLifecycleSetup() || u.isVaultLocked() { + return layout.Dimensions{} + } + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return u.headerActions(gtx) + } + if u.shouldShowDesktopWorkingHeader() { + return layout.Dimensions{} + } + return card(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return u.brandMark(gtx, 196, 56) + }), + layout.Rigid(u.headerActions), + ) + }) +} + +func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { + if u.shouldShowLifecycleSetup() || u.isVaultLocked() || u.shouldShowDesktopWorkingHeader() { + return layout.Dimensions{} + } + spacing := gtx.Dp(unit.Dp(8)) + metrics := headerlayout.ActionMetrics{Spacing: spacing} + row := func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + metrics.SyncDims = u.syncButtonGroup(gtx) + return metrics.SyncDims + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + btn := material.Button(u.theme, &u.lockVault, "Lock") + metrics.LockDims = btn.Layout(gtx) + return metrics.LockDims + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + metrics.MainDims = u.mainMenuButtonGroup(gtx) + return metrics.MainDims + }), + ) + } + + rowOps := op.Record(gtx.Ops) + metrics.RowDims = row(gtx) + rowCall := rowOps.Stop() + + if u.usesCompactViewport() { + metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X) + } + + surface := headerlayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0} + + rowStack := op.Offset(image.Pt(metrics.RowOriginX, 0)).Push(gtx.Ops) + rowCall.Add(gtx.Ops) + rowStack.Pop() + + if u.usesCompactViewport() { + if u.syncMenuOpen { + u.phoneSyncMenuVisible = true + u.phoneSyncMenuAnchor = metrics.SyncAnchor().Point() + } + if u.mainMenuOpen { + u.phoneMainMenuVisible = true + u.phoneMainMenuAnchor = metrics.MainAnchor().Point() + } + return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, metrics.RowDims.Size.Y)} + } + + if u.syncMenuOpen { + surface.Draw(gtx, metrics.SyncAnchor(), u.syncMenu) + } + if u.mainMenuOpen { + surface.Draw(gtx, metrics.MainAnchor(), u.mainMenu) + } + + return layout.Dimensions{Size: image.Pt(metrics.RowDims.Size.X, metrics.RowDims.Size.Y)} +} + +func (u *ui) topRightActionOrder() []string { + if u.isVaultLocked() { + return nil + } + return []string{"Sync", "Lock", "Menu"} +} + +func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { + if !u.usesCompactViewport() || (!u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone()) { + return layout.Dimensions{} + } + gtx.Constraints.Min = gtx.Constraints.Max + contentInsetPx := gtx.Dp(unit.Dp(16)) + surface := headerlayout.DropdownSurface{ + ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)), + LeftInset: contentInsetPx, + TopInset: contentInsetPx, + } + + if u.syncMenuVisibleOnPhone() { + surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu) + } + if u.mainMenuVisibleOnPhone() { + surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu) + } + return layout.Dimensions{Size: gtx.Constraints.Max} +} + +func (u *ui) syncMenuVisibleOnPhone() bool { + return u.usesCompactViewport() && u.phoneSyncMenuVisible && u.syncMenuOpen +} + +func (u *ui) mainMenuVisibleOnPhone() bool { + return u.usesCompactViewport() && u.phoneMainMenuVisible && u.mainMenuOpen +} + +func (u *ui) syncMenuDropsBelowTrigger() bool { return true } + +func (u *ui) syncMenuRightAlignsToTrigger() bool { return true } + +func (u *ui) headerMenusUseOverlayModel() bool { return true } + +func (u *ui) mainMenuDropsBelowTrigger() bool { return true } + +func (u *ui) mainMenuRightAlignsToTrigger() bool { return true } + +func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions { + if !u.usesCompactViewport() { + return layout.Dimensions{} + } + return layout.Dimensions{} +} + +func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions { + if u.usesCompactViewport() { + return u.brandImage(gtx, u.splashSquare, widthDP, heightDP) + } + return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP) +} + +func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP float32) layout.Dimensions { + width := gtx.Dp(unit.Dp(widthDP)) + height := gtx.Dp(unit.Dp(heightDP)) + if width > gtx.Constraints.Max.X { + width = gtx.Constraints.Max.X + } + if height > gtx.Constraints.Max.Y && gtx.Constraints.Max.Y > 0 { + height = gtx.Constraints.Max.Y + } + img := widget.Image{ + Src: src, + Fit: widget.Contain, + Position: layout.W, + Scale: 1.0 / gtx.Metric.PxPerDp, + } + gtx.Constraints.Min = image.Point{} + gtx.Constraints.Max = image.Pt(width, height) + return img.Layout(gtx) +} diff --git a/internal/appui/layout/header/dropdown.go b/internal/appui/header/layout/dropdown.go similarity index 99% rename from internal/appui/layout/header/dropdown.go rename to internal/appui/header/layout/dropdown.go index 0e18456..25bb94e 100644 --- a/internal/appui/layout/header/dropdown.go +++ b/internal/appui/header/layout/dropdown.go @@ -1,4 +1,4 @@ -package header +package layout import ( "image" diff --git a/internal/appui/header_main_menu.go b/internal/appui/header_main_menu.go new file mode 100644 index 0000000..d7fdbd2 --- /dev/null +++ b/internal/appui/header_main_menu.go @@ -0,0 +1,64 @@ +package appui + +import ( + "image/color" + + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget/material" +) + +func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions { + rows := []layout.Widget{ + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showEntries, "Entries") + }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin") + }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens") + }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit") + }, + func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") }, + func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings") + }, + } + rowWidth := menuActionWidth(gtx, rows) + return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[0]) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[1]) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[2]) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[3]) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[4]) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[5]) }), + ) + }) +} + +func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions { + icon := u.menuIcon + if icon == nil { + icon = u.settingsIcon + } + btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu") + if u.mainMenuOpen { + btn.Background = accentColor + btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} + } else { + btn.Background = selectedColor + btn.Color = accentColor + } + btn.Size = unit.Dp(18) + btn.Inset = layout.UniformInset(unit.Dp(8)) + return btn.Layout(gtx) +} diff --git a/internal/appui/header_menu_layout.go b/internal/appui/header_menu_layout.go new file mode 100644 index 0000000..458bc90 --- /dev/null +++ b/internal/appui/header_menu_layout.go @@ -0,0 +1,52 @@ +package appui + +import ( + "image" + + "gioui.org/layout" + "gioui.org/op" + "gioui.org/unit" +) + +func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions { + measureGTX := gtx + measureGTX.Constraints.Min = image.Point{} + measureGTX.Constraints.Max.X = gtx.Constraints.Max.X + macro := op.Record(gtx.Ops) + contentDims := w(measureGTX) + _ = macro.Stop() + width := contentDims.Size.X + gtx.Dp(unit.Dp(20)) + maxWidth := gtx.Constraints.Max.X + if maxWidth > 0 && width > maxWidth { + width = maxWidth + } + if width > 0 { + gtx.Constraints.Min.X = width + gtx.Constraints.Max.X = width + } + return compactCard(gtx, w) +} + +func menuActionWidth(gtx layout.Context, rows []layout.Widget) int { + width := 0 + for _, row := range rows { + measureGTX := gtx + measureGTX.Constraints.Min = image.Point{} + macro := op.Record(gtx.Ops) + dims := row(measureGTX) + _ = macro.Stop() + if dims.Size.X > width { + width = dims.Size.X + } + } + return width +} + +func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions { + if width <= 0 { + return child(gtx) + } + gtx.Constraints.Min.X = width + gtx.Constraints.Max.X = width + return layout.E.Layout(gtx, child) +} diff --git a/internal/appui/ui_layout_header.go b/internal/appui/header_sync_menu.go similarity index 50% rename from internal/appui/ui_layout_header.go rename to internal/appui/header_sync_menu.go index 1c55acd..c077e90 100644 --- a/internal/appui/ui_layout_header.go +++ b/internal/appui/header_sync_menu.go @@ -1,177 +1,33 @@ package appui import ( - "image" "image/color" + "runtime" + "strings" "gioui.org/layout" - "gioui.org/op" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" - "git.julianfamily.org/keepassgo/internal/appui/actions" - headerlayout "git.julianfamily.org/keepassgo/internal/appui/layout/header" + "git.julianfamily.org/keepassgo/internal/appstate" + syncmodel "git.julianfamily.org/keepassgo/internal/appui/sync" "git.julianfamily.org/keepassgo/internal/vault" ) -func (u *ui) header(gtx layout.Context) layout.Dimensions { - if u.usesCompactViewport() { - if u.shouldShowLifecycleSetup() || u.isVaultLocked() { - return layout.Dimensions{} - } - gtx.Constraints.Min.X = gtx.Constraints.Max.X - return u.headerActions(gtx) - } - if u.shouldShowDesktopWorkingHeader() { - return layout.Dimensions{} - } - return card(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - return u.brandMark(gtx, 196, 56) - }), - layout.Rigid(u.headerActions), - ) - }) -} - -func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { - if u.shouldShowLifecycleSetup() { - return layout.Dimensions{} - } - if u.isVaultLocked() { - return layout.Dimensions{} - } - if u.shouldShowDesktopWorkingHeader() { - return layout.Dimensions{} - } - spacing := gtx.Dp(unit.Dp(8)) - metrics := headerlayout.ActionMetrics{Spacing: spacing} - row := func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - metrics.SyncDims = u.syncButtonGroup(gtx) - return metrics.SyncDims - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.Button(u.theme, &u.lockVault, "Lock") - metrics.LockDims = btn.Layout(gtx) - return metrics.LockDims - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - metrics.MainDims = u.mainMenuButtonGroup(gtx) - return metrics.MainDims - }), - ) - } - - rowOps := op.Record(gtx.Ops) - metrics.RowDims = row(gtx) - rowCall := rowOps.Stop() - - if u.usesCompactViewport() { - metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X) - } - - surface := headerlayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0} - - rowStack := op.Offset(image.Pt(metrics.RowOriginX, 0)).Push(gtx.Ops) - rowCall.Add(gtx.Ops) - rowStack.Pop() - - if u.usesCompactViewport() { - if u.syncMenuOpen { - u.phoneSyncMenuVisible = true - u.phoneSyncMenuAnchor = metrics.SyncAnchor().Point() - } - if u.mainMenuOpen { - u.phoneMainMenuVisible = true - u.phoneMainMenuAnchor = metrics.MainAnchor().Point() - } - width := gtx.Constraints.Max.X - return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)} - } - - if u.syncMenuOpen { - surface.Draw(gtx, metrics.SyncAnchor(), u.syncMenu) - } - if u.mainMenuOpen { - surface.Draw(gtx, metrics.MainAnchor(), u.mainMenu) - } - - width := metrics.RowDims.Size.X - return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)} -} - -func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions { - rows := []layout.Widget{ - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showEntries, "Entries") - }, - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin") - }, - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens") - }, - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit") - }, - func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") }, - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings") - }, - } - rowWidth := menuActionWidth(gtx, rows) - return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, rowWidth, rows[0]) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, rowWidth, rows[1]) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, rowWidth, rows[2]) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, rowWidth, rows[3]) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, rowWidth, rows[4]) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return rightAlignedMenuAction(gtx, rowWidth, rows[5]) - }), - ) - }) -} - func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions { - label := "Sync" spacing := unit.Dp(4) if u.usesCompactViewport() { spacing = unit.Dp(3) } - row := func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, label, u.usesCompactViewport()) - }), - layout.Rigid(layout.Spacer{Width: spacing}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return u.syncMenuToggle(gtx) - }), - ) - } - return row(gtx) + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, "Sync", u.usesCompactViewport()) + }), + layout.Rigid(layout.Spacer{Width: spacing}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.syncMenuToggle(gtx) + }), + ) } func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions { @@ -205,12 +61,11 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { actionRows := u.syncMenuActionRows(model) actionWidth := menuActionWidth(gtx, actionRows) return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { - rows := u.syncMenuRows(model, profiles, credentials, actionWidth) - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...) + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.syncMenuRows(model, profiles, credentials, actionWidth)...) }) } -func (u *ui) syncMenuActionRows(model actions.SyncMenuModel) []layout.Widget { +func (u *ui) syncMenuActionRows(model syncmodel.MenuModel) []layout.Widget { rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") @@ -244,7 +99,7 @@ func (u *ui) syncMenuActionRows(model actions.SyncMenuModel) []layout.Widget { return rows } -func (u *ui) syncMenuRows(model actions.SyncMenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry, actionWidth int) []layout.FlexChild { +func (u *ui) syncMenuRows(model syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry, actionWidth int) []layout.FlexChild { rows := u.syncMenuPrimaryRows(model, actionWidth) rows = append(rows, u.syncMenuSavedBindingRows(model, profiles, credentials)...) if model.ShowSaveCurrentBinding { @@ -253,7 +108,7 @@ func (u *ui) syncMenuRows(model actions.SyncMenuModel, profiles []vault.RemotePr return rows } -func (u *ui) syncMenuPrimaryRows(model actions.SyncMenuModel, actionWidth int) []layout.FlexChild { +func (u *ui) syncMenuPrimaryRows(model syncmodel.MenuModel, actionWidth int) []layout.FlexChild { rows := []layout.FlexChild{ layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(11), "Need another source or direction?") @@ -302,7 +157,7 @@ func (u *ui) syncMenuActionRow(actionWidth int, click *widget.Clickable, label s }) } -func (u *ui) syncMenuSavedBindingRows(model actions.SyncMenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild { +func (u *ui) syncMenuSavedBindingRows(model syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild { if !u.hasOpenVault() || len(profiles) == 0 || len(credentials) == 0 { return nil } @@ -333,7 +188,7 @@ func (u *ui) syncMenuSavedBindingRows(model actions.SyncMenuModel, profiles []va return rows } -func (u *ui) syncMenuSavedBindingSummary(model actions.SyncMenuModel) layout.Widget { +func (u *ui) syncMenuSavedBindingSummary(model syncmodel.MenuModel) layout.Widget { return func(gtx layout.Context) layout.Dimensions { summary := model.SavedBindingSummary if !summary.OK { @@ -365,7 +220,7 @@ func (u *ui) syncMenuSavedBindingSummary(model actions.SyncMenuModel) layout.Wid } } -func (u *ui) syncMenuSaveBindingRows(model actions.SyncMenuModel) []layout.FlexChild { +func (u *ui) syncMenuSaveBindingRows(model syncmodel.MenuModel) []layout.FlexChild { return []layout.FlexChild{ layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -380,7 +235,7 @@ func (u *ui) syncMenuSaveBindingRows(model actions.SyncMenuModel) []layout.FlexC } } -func (u *ui) syncMenuSelectorRows(_ actions.SyncMenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild { +func (u *ui) syncMenuSelectorRows(_ syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild { rows := make([]layout.FlexChild, 0, len(profiles)+len(credentials)+4) for i, profile := range profiles { i := i @@ -422,125 +277,44 @@ func (u *ui) syncMenuSelectorRows(_ actions.SyncMenuModel, profiles []vault.Remo return rows } -func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions { - measureGTX := gtx - measureGTX.Constraints.Min = image.Point{} - measureGTX.Constraints.Max.X = gtx.Constraints.Max.X - macro := op.Record(gtx.Ops) - contentDims := w(measureGTX) - _ = macro.Stop() - width := contentDims.Size.X + gtx.Dp(unit.Dp(20)) - maxWidth := gtx.Constraints.Max.X - if maxWidth > 0 && width > maxWidth { - width = maxWidth +func (u *ui) buildSyncMenuModel() syncmodel.MenuModel { + model := syncmodel.MenuModel{ + HasOpenVault: u.hasOpenVault(), + ShowSelectors: u.shouldShowSavedRemoteBindingSelectors(), + ShowShare: supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "", + RemoteBaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), + RemotePath: strings.TrimSpace(u.remotePath.Text()), + RemoteUsername: strings.TrimSpace(u.remoteUsername.Text()), + RemotePassword: u.remotePassword.Text(), + SelectedVaultSyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode), } - if width > 0 { - gtx.Constraints.Min.X = width - gtx.Constraints.Max.X = width + _, model.HasSelectedBinding = u.selectedVaultRemoteBinding() + model.SavedBindingSummary = u.computeSavedRemoteBindingSummary() + model.ShowSaveCurrentBinding = model.HasOpenVault && model.RemoteBaseURL != "" && model.RemotePath != "" && model.RemoteUsername != "" && model.RemotePassword != "" + return model +} + +func (u *ui) computeSavedRemoteBindingSummary() syncmodel.MenuBindingSummary { + profile, ok := u.selectedVaultRemoteProfile() + if !ok { + return syncmodel.MenuBindingSummary{} } - return compactCard(gtx, w) -} - -func (u *ui) topRightActionOrder() []string { - if u.isVaultLocked() { - return nil + entry, ok := u.selectedVaultRemoteCredentialEntry() + if !ok { + return syncmodel.MenuBindingSummary{} } - return []string{"Sync", "Lock", "Menu"} -} - -func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions { - button := func(gtx layout.Context) layout.Dimensions { - icon := u.menuIcon - if icon == nil { - icon = u.settingsIcon - } - btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu") - if u.mainMenuOpen { - btn.Background = accentColor - btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} - } else { - btn.Background = selectedColor - btn.Color = accentColor - } - btn.Size = unit.Dp(18) - btn.Inset = layout.UniformInset(unit.Dp(8)) - return btn.Layout(gtx) + credentialLabel := entry.Title + if strings.TrimSpace(entry.Username) != "" { + credentialLabel += " · " + strings.TrimSpace(entry.Username) } - return button(gtx) -} - -func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { - if !u.usesCompactViewport() { - return layout.Dimensions{} + syncLabel := "Sync manually when you choose Use Remote Sync." + if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave { + syncLabel = "Syncs automatically on open and save." } - if !u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone() { - return layout.Dimensions{} + return syncmodel.MenuBindingSummary{ + ProfileLabel: profile.Name, + CredentialLabel: credentialLabel, + SyncLabel: syncLabel, + OK: true, } - gtx.Constraints.Min = gtx.Constraints.Max - contentInsetPx := gtx.Dp(unit.Dp(16)) - surface := headerlayout.DropdownSurface{ - ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)), - LeftInset: contentInsetPx, - TopInset: contentInsetPx, - } - - if u.syncMenuVisibleOnPhone() { - surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu) - } - if u.mainMenuVisibleOnPhone() { - surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu) - } - return layout.Dimensions{Size: gtx.Constraints.Max} -} - -func (u *ui) syncMenuVisibleOnPhone() bool { - return u.usesCompactViewport() && u.phoneSyncMenuVisible && u.syncMenuOpen -} - -func (u *ui) mainMenuVisibleOnPhone() bool { - return u.usesCompactViewport() && u.phoneMainMenuVisible && u.mainMenuOpen -} - -func (u *ui) syncMenuDropsBelowTrigger() bool { - return true -} - -func (u *ui) syncMenuRightAlignsToTrigger() bool { - return true -} - -func (u *ui) headerMenusUseOverlayModel() bool { - return true -} - -func (u *ui) mainMenuDropsBelowTrigger() bool { - return true -} - -func (u *ui) mainMenuRightAlignsToTrigger() bool { - return true -} - -func menuActionWidth(gtx layout.Context, rows []layout.Widget) int { - width := 0 - for _, row := range rows { - measureGTX := gtx - measureGTX.Constraints.Min = image.Point{} - macro := op.Record(gtx.Ops) - dims := row(measureGTX) - _ = macro.Stop() - if dims.Size.X > width { - width = dims.Size.X - } - } - return width -} - -func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions { - if width <= 0 { - return child(gtx) - } - gtx.Constraints.Min.X = width - gtx.Constraints.Max.X = width - return layout.E.Layout(gtx, child) } diff --git a/internal/appui/ui_keyboard.go b/internal/appui/input.go similarity index 72% rename from internal/appui/ui_keyboard.go rename to internal/appui/input.go index 302dedc..564e7ce 100644 --- a/internal/appui/ui_keyboard.go +++ b/internal/appui/input.go @@ -5,28 +5,42 @@ import ( "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 string +type detailField = editormodel.Field 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" + 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 { @@ -41,6 +55,68 @@ 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 @@ -336,19 +412,7 @@ func (u *ui) focusedDetailIndex() int { } func detailFocusOrder() []detailField { - return []detailField{ - detailFieldID, - detailFieldTitle, - detailFieldUsername, - detailFieldPassword, - detailFieldURL, - detailFieldPath, - detailFieldTags, - detailFieldPasswordProfile, - detailFieldNotes, - detailFieldFields, - detailFieldHistoryIndex, - } + return editormodel.FocusOrder() } func canonicalFocusID(id focusID) focusID { diff --git a/internal/appui/lifecycle/model.go b/internal/appui/lifecycle/model.go new file mode 100644 index 0000000..17c4339 --- /dev/null +++ b/internal/appui/lifecycle/model.go @@ -0,0 +1,9 @@ +package lifecycle + +type OpenIntent string + +const ( + OpenIntentNone OpenIntent = "" + OpenIntentRemoteSyncSetup OpenIntent = "remote_sync_setup" + OpenIntentRemoteSyncSettings OpenIntent = "remote_sync_settings" +) diff --git a/internal/appui/ui_actions_lifecycle.go b/internal/appui/lifecycle_actions.go similarity index 100% rename from internal/appui/ui_actions_lifecycle.go rename to internal/appui/lifecycle_actions.go diff --git a/internal/appui/ui_forms.go b/internal/appui/lifecycle_forms.go similarity index 99% rename from internal/appui/ui_forms.go rename to internal/appui/lifecycle_forms.go index 1acd90c..f41d44f 100644 --- a/internal/appui/ui_forms.go +++ b/internal/appui/lifecycle_forms.go @@ -18,6 +18,23 @@ import ( "git.julianfamily.org/keepassgo/internal/appstate" ) +func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions { + panel := card + if u.usesCompactViewport() { + panel = compactCard + } + return panel(gtx, func(gtx layout.Context) layout.Dimensions { + rows := []layout.Widget{ + u.lifecycleBranding, + layout.Spacer{Height: unit.Dp(8)}.Layout, + u.lifecycleControls, + } + return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { + return rows[i](gtx) + }) + }) +} + func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { busy := u.lifecycleBusy() showLocalChooser := u.showLocalVaultChooser() diff --git a/internal/appui/layout/list/sections.go b/internal/appui/list/layout/sections.go similarity index 94% rename from internal/appui/layout/list/sections.go rename to internal/appui/list/layout/sections.go index e6c6fb1..e8f2c61 100644 --- a/internal/appui/layout/list/sections.go +++ b/internal/appui/list/layout/sections.go @@ -1,4 +1,4 @@ -package list +package layout type TopSection string diff --git a/internal/appui/list/model.go b/internal/appui/list/model.go new file mode 100644 index 0000000..97c3be5 --- /dev/null +++ b/internal/appui/list/model.go @@ -0,0 +1,8 @@ +package list + +type EntriesSectionState struct { + Path []string + SearchQuery string + SelectedEntryID string + Editing bool +} diff --git a/internal/appui/main_test.go b/internal/appui/main_test.go index 359b437..b745dcf 100644 --- a/internal/appui/main_test.go +++ b/internal/appui/main_test.go @@ -25,8 +25,8 @@ import ( "git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/appstate" - headerlayout "git.julianfamily.org/keepassgo/internal/appui/layout/header" - listlayout "git.julianfamily.org/keepassgo/internal/appui/layout/list" + headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout" + listlayout "git.julianfamily.org/keepassgo/internal/appui/list/layout" "git.julianfamily.org/keepassgo/internal/clipboard" "git.julianfamily.org/keepassgo/internal/passwords" "git.julianfamily.org/keepassgo/internal/session" diff --git a/internal/appui/ui_recent_state.go b/internal/appui/recent_state.go similarity index 100% rename from internal/appui/ui_recent_state.go rename to internal/appui/recent_state.go diff --git a/internal/appui/ui_runtime.go b/internal/appui/runtime.go similarity index 100% rename from internal/appui/ui_runtime.go rename to internal/appui/runtime.go diff --git a/internal/appui/ui_preferences.go b/internal/appui/settings.go similarity index 65% rename from internal/appui/ui_preferences.go rename to internal/appui/settings.go index 8cb0edf..ebfa108 100644 --- a/internal/appui/ui_preferences.go +++ b/internal/appui/settings.go @@ -2,6 +2,8 @@ package appui import ( "encoding/json" + "fmt" + "image" "image/color" "os" "path/filepath" @@ -9,29 +11,28 @@ import ( "time" "gioui.org/layout" + "gioui.org/op/clip" + "gioui.org/op/paint" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" + editormodel "git.julianfamily.org/keepassgo/internal/appui/editor" + settingsmodel "git.julianfamily.org/keepassgo/internal/appui/settings" "git.julianfamily.org/keepassgo/internal/vault" ) const ( - displayDensityDense = "dense" - displayDensityComfortable = "comfortable" + displayDensityDense = settingsmodel.DisplayDensityDense + displayDensityComfortable = settingsmodel.DisplayDensityComfortable - contrastStandard = "standard" - contrastHigh = "high" + contrastStandard = settingsmodel.ContrastStandard + contrastHigh = settingsmodel.ContrastHigh - keyboardFocusStandard = "standard" - keyboardFocusProminent = "prominent" + keyboardFocusStandard = settingsmodel.KeyboardFocusStandard + keyboardFocusProminent = settingsmodel.KeyboardFocusProminent ) -type accessibilityPreferences struct { - DisplayDensity string - Contrast string - ReducedMotion bool - KeyboardFocus string -} +type accessibilityPreferences = settingsmodel.AccessibilityPreferences type settingsFile struct { Sync syncSettings `json:"sync,omitempty"` @@ -63,37 +64,23 @@ type choiceSpec struct { Active bool } +type focusAppearance struct { + BorderColor color.NRGBA + OutlineColor color.NRGBA + OutlineWidth int + MinHeight int +} + func defaultAccessibilityPreferences() accessibilityPreferences { - return accessibilityPreferences{ - DisplayDensity: displayDensityForDenseLayout(true), - Contrast: contrastStandard, - KeyboardFocus: keyboardFocusStandard, - } + return settingsmodel.DefaultAccessibilityPreferences() } func displayDensityForDenseLayout(dense bool) string { - if dense { - return displayDensityDense - } - return displayDensityComfortable + return settingsmodel.DisplayDensityForDenseLayout(dense) } func normalizeAccessibilityPreferences(prefs accessibilityPreferences) accessibilityPreferences { - normalized := defaultAccessibilityPreferences() - switch prefs.DisplayDensity { - case displayDensityDense, displayDensityComfortable: - normalized.DisplayDensity = prefs.DisplayDensity - } - switch prefs.Contrast { - case contrastStandard, contrastHigh: - normalized.Contrast = prefs.Contrast - } - switch prefs.KeyboardFocus { - case keyboardFocusStandard, keyboardFocusProminent: - normalized.KeyboardFocus = prefs.KeyboardFocus - } - normalized.ReducedMotion = prefs.ReducedMotion - return normalized + return settingsmodel.NormalizeAccessibilityPreferences(prefs) } func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) { @@ -102,6 +89,96 @@ func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) { u.accessibilityPrefs = normalized } +func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance { + prefs = normalizeAccessibilityPreferences(prefs) + 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 prefs.DisplayDensity == displayDensityComfortable { + appearance.MinHeight = metric.Dp(unit.Dp(52)) + } + if prefs.Contrast == contrastHigh { + appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255} + } + 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))) + if prefs.Contrast == contrastHigh { + appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255} + appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124} + } + if prefs.KeyboardFocus == keyboardFocusProminent { + appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3))) + appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148} + } + } + return appearance +} + +func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) { + prefs = normalizeAccessibilityPreferences(prefs) + background = color.NRGBA{R: 231, G: 239, B: 235, A: 255} + text = accentColor + if prefs.Contrast == contrastHigh { + background = color.NRGBA{R: 225, G: 235, B: 230, A: 255} + text = color.NRGBA{R: 19, G: 57, B: 43, A: 255} + } + if focused { + background = color.NRGBA{R: 214, G: 229, B: 221, A: 255} + if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent { + background = color.NRGBA{R: 202, G: 222, B: 212, 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 { + return editormodel.Label(field) +} + func (u *ui) loadSettingsDraft() { u.settingsDraft = settingsDraft{ Accessibility: accessibilityPreferences{ diff --git a/internal/appui/settings/model.go b/internal/appui/settings/model.go new file mode 100644 index 0000000..b55dfe8 --- /dev/null +++ b/internal/appui/settings/model.go @@ -0,0 +1,50 @@ +package settings + +type AccessibilityPreferences struct { + DisplayDensity string + Contrast string + ReducedMotion bool + KeyboardFocus string +} + +const ( + DisplayDensityDense = "dense" + DisplayDensityComfortable = "comfortable" + ContrastStandard = "standard" + ContrastHigh = "high" + KeyboardFocusStandard = "standard" + KeyboardFocusProminent = "prominent" +) + +func DefaultAccessibilityPreferences() AccessibilityPreferences { + return AccessibilityPreferences{ + DisplayDensity: DisplayDensityDense, + Contrast: ContrastStandard, + KeyboardFocus: KeyboardFocusStandard, + } +} + +func DisplayDensityForDenseLayout(dense bool) string { + if dense { + return DisplayDensityDense + } + return DisplayDensityComfortable +} + +func NormalizeAccessibilityPreferences(prefs AccessibilityPreferences) AccessibilityPreferences { + normalized := DefaultAccessibilityPreferences() + switch prefs.DisplayDensity { + case DisplayDensityDense, DisplayDensityComfortable: + normalized.DisplayDensity = prefs.DisplayDensity + } + switch prefs.Contrast { + case ContrastStandard, ContrastHigh: + normalized.Contrast = prefs.Contrast + } + switch prefs.KeyboardFocus { + case KeyboardFocusStandard, KeyboardFocusProminent: + normalized.KeyboardFocus = prefs.KeyboardFocus + } + normalized.ReducedMotion = prefs.ReducedMotion + return normalized +} diff --git a/internal/appui/sync/model.go b/internal/appui/sync/model.go new file mode 100644 index 0000000..b73f900 --- /dev/null +++ b/internal/appui/sync/model.go @@ -0,0 +1,119 @@ +package sync + +import "git.julianfamily.org/keepassgo/internal/appstate" + +type SourceMode string + +const ( + SourceLocal SourceMode = "local" + SourceRemote SourceMode = "remote" +) + +type Direction string + +const ( + DirectionPull Direction = "pull" + DirectionPush Direction = "push" +) + +type DialogPurpose string + +const ( + DialogPurposeAdvanced DialogPurpose = "advanced" + DialogPurposeRemoteSetup DialogPurpose = "remote-setup" +) + +type MenuModel struct { + HasOpenVault bool + HasSelectedBinding bool + ShowSelectors bool + ShowShare bool + ShowSaveCurrentBinding bool + SavedBindingSummary MenuBindingSummary + RemoteBaseURL string + RemotePath string + RemoteUsername string + RemotePassword string + SelectedVaultSyncMode appstate.SyncMode +} + +type MenuBindingSummary struct { + ProfileLabel string + CredentialLabel string + SyncLabel string + OK bool +} + +func (m MenuModel) SavedBindingHeading() string { + if !m.ShowSelectors { + return "Use this vault's saved remote sync target" + } + return "Use a saved remote profile from this vault" +} + +func (m MenuModel) OpenSelectedButtonLabel() string { + if !m.ShowSelectors { + return "Use Remote Sync" + } + return "Open Saved Remote" +} + +func (m MenuModel) ShowDirectRemoteSyncShortcut() bool { + return m.HasOpenVault && m.HasSelectedBinding +} + +func (m MenuModel) DirectRemoteSyncShortcutLabel() string { return "Use Remote Sync" } + +func (m MenuModel) ShowRemoteSyncSettingsShortcut() bool { + return m.HasOpenVault && m.HasSelectedBinding +} + +func (m MenuModel) RemoteSyncSettingsShortcutLabel() string { return "Remote Sync Settings" } + +func (m MenuModel) ShowRemoveRemoteSyncShortcut() bool { return m.ShowRemoteSyncSettingsShortcut() } + +func (m MenuModel) RemoveRemoteSyncShortcutLabel() string { return "Stop Using Remote Sync" } + +func (m MenuModel) ShowRemoteSyncSetupShortcut() bool { + return m.HasOpenVault && !m.HasSelectedBinding +} + +func (m MenuModel) RemoteSyncSetupShortcutLabel() string { return "Set Up Remote Sync" } + +func (m MenuModel) ActionLabels() []string { + labels := []string{"Open Advanced Sync"} + if m.ShowRemoteSyncSetupShortcut() { + labels = append(labels, m.RemoteSyncSetupShortcutLabel()) + } + if m.ShowDirectRemoteSyncShortcut() { + labels = append(labels, m.DirectRemoteSyncShortcutLabel()) + } + if m.ShowRemoteSyncSettingsShortcut() { + labels = append(labels, m.RemoteSyncSettingsShortcutLabel()) + } + if m.ShowRemoveRemoteSyncShortcut() { + labels = append(labels, m.RemoveRemoteSyncShortcutLabel()) + } + return labels +} + +func (m MenuModel) SaveCurrentRemoteBindingHeading() string { + return "Bind this local vault to the current remote target" +} + +func (m MenuModel) SaveCurrentRemoteBindingButtonLabel() string { return "Save Remote In Vault" } + +func SummaryText(purpose DialogPurpose, source SourceMode, direction Direction) string { + if purpose == DialogPurposeRemoteSetup { + return "Push this local vault to a WebDAV target and save that target for future sync." + } + sourceLabel := "another local vault file" + if source == SourceRemote { + sourceLabel = "another WebDAV-backed vault" + } + action := "Pull changes from" + if direction == DirectionPush { + action = "Push the current vault into" + } + return action + " " + sourceLabel + "." +} diff --git a/internal/appui/ui_sync_dialog.go b/internal/appui/sync_dialog.go similarity index 100% rename from internal/appui/ui_sync_dialog.go rename to internal/appui/sync_dialog.go diff --git a/internal/appui/ui_accessibility.go b/internal/appui/ui_accessibility.go deleted file mode 100644 index 2954f2b..0000000 --- a/internal/appui/ui_accessibility.go +++ /dev/null @@ -1,135 +0,0 @@ -package appui - -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, prefs accessibilityPreferences, focused bool) focusAppearance { - prefs = normalizeAccessibilityPreferences(prefs) - 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 prefs.DisplayDensity == displayDensityComfortable { - appearance.MinHeight = metric.Dp(unit.Dp(52)) - } - if prefs.Contrast == contrastHigh { - appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255} - } - 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))) - if prefs.Contrast == contrastHigh { - appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255} - appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124} - } - if prefs.KeyboardFocus == keyboardFocusProminent { - appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3))) - appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148} - } - } - return appearance -} - -func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) { - prefs = normalizeAccessibilityPreferences(prefs) - background = color.NRGBA{R: 231, G: 239, B: 235, A: 255} - text = accentColor - if prefs.Contrast == contrastHigh { - background = color.NRGBA{R: 225, G: 235, B: 230, A: 255} - text = color.NRGBA{R: 19, G: 57, B: 43, A: 255} - } - if focused { - background = color.NRGBA{R: 214, G: 229, B: 221, A: 255} - if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent { - background = color.NRGBA{R: 202, G: 222, B: 212, 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/internal/appui/ui_branding.go b/internal/appui/ui_branding.go deleted file mode 100644 index 396d742..0000000 --- a/internal/appui/ui_branding.go +++ /dev/null @@ -1,44 +0,0 @@ -package appui - -import ( - "image" - - "gioui.org/layout" - "gioui.org/op/paint" - "gioui.org/unit" - "gioui.org/widget" -) - -func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions { - if !u.usesCompactViewport() { - return layout.Dimensions{} - } - return layout.Dimensions{} -} - -func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions { - if u.usesCompactViewport() { - return u.brandImage(gtx, u.splashSquare, widthDP, heightDP) - } - return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP) -} - -func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP float32) layout.Dimensions { - width := gtx.Dp(unit.Dp(widthDP)) - height := gtx.Dp(unit.Dp(heightDP)) - if width > gtx.Constraints.Max.X { - width = gtx.Constraints.Max.X - } - if height > gtx.Constraints.Max.Y && gtx.Constraints.Max.Y > 0 { - height = gtx.Constraints.Max.Y - } - img := widget.Image{ - Src: src, - Fit: widget.Contain, - Position: layout.W, - Scale: 1.0 / gtx.Metric.PxPerDp, - } - gtx.Constraints.Min = image.Point{} - gtx.Constraints.Max = image.Pt(width, height) - return img.Layout(gtx) -} diff --git a/internal/appui/ui_layout_lifecycle.go b/internal/appui/ui_layout_lifecycle.go deleted file mode 100644 index 77ee870..0000000 --- a/internal/appui/ui_layout_lifecycle.go +++ /dev/null @@ -1,24 +0,0 @@ -package appui - -import ( - "gioui.org/layout" - "gioui.org/unit" - "gioui.org/widget/material" -) - -func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions { - panel := card - if u.usesCompactViewport() { - panel = compactCard - } - return panel(gtx, func(gtx layout.Context) layout.Dimensions { - rows := []layout.Widget{ - u.lifecycleBranding, - layout.Spacer{Height: unit.Dp(8)}.Layout, - u.lifecycleControls, - } - return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { - return rows[i](gtx) - }) - }) -} diff --git a/internal/appui/ui_shortcuts.go b/internal/appui/ui_shortcuts.go deleted file mode 100644 index 9144b0e..0000000 --- a/internal/appui/ui_shortcuts.go +++ /dev/null @@ -1,83 +0,0 @@ -package appui - -import ( - "strings" - - "gioui.org/io/event" - "gioui.org/io/key" - "gioui.org/layout" - - "git.julianfamily.org/keepassgo/internal/clipboard" -) - -const ( - shortcutSearch = "search" - shortcutSave = "save" - shortcutLock = "lock" - shortcutNewEntry = "new-entry" - shortcutCopyUser = "copy-user" - shortcutCopyPassword = "copy-password" - shortcutCopyURL = "copy-url" -) - -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 - } -} diff --git a/internal/appui/ui_sync_menu_actions.go b/internal/appui/ui_sync_menu_actions.go deleted file mode 100644 index edc3bce..0000000 --- a/internal/appui/ui_sync_menu_actions.go +++ /dev/null @@ -1,51 +0,0 @@ -package appui - -import ( - "runtime" - "strings" - - "git.julianfamily.org/keepassgo/internal/appstate" - appuiactions "git.julianfamily.org/keepassgo/internal/appui/actions" -) - -func (u *ui) buildSyncMenuModel() appuiactions.SyncMenuModel { - model := appuiactions.SyncMenuModel{ - HasOpenVault: u.hasOpenVault(), - ShowSelectors: u.shouldShowSavedRemoteBindingSelectors(), - ShowShare: supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "", - RemoteBaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), - RemotePath: strings.TrimSpace(u.remotePath.Text()), - RemoteUsername: strings.TrimSpace(u.remoteUsername.Text()), - RemotePassword: u.remotePassword.Text(), - SelectedVaultSyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode), - } - _, model.HasSelectedBinding = u.selectedVaultRemoteBinding() - model.SavedBindingSummary = u.computeSavedRemoteBindingSummary() - model.ShowSaveCurrentBinding = model.HasOpenVault && model.RemoteBaseURL != "" && model.RemotePath != "" && model.RemoteUsername != "" && model.RemotePassword != "" - return model -} - -func (u *ui) computeSavedRemoteBindingSummary() appuiactions.SyncMenuBindingSummary { - profile, ok := u.selectedVaultRemoteProfile() - if !ok { - return appuiactions.SyncMenuBindingSummary{} - } - entry, ok := u.selectedVaultRemoteCredentialEntry() - if !ok { - return appuiactions.SyncMenuBindingSummary{} - } - credentialLabel := entry.Title - if strings.TrimSpace(entry.Username) != "" { - credentialLabel += " · " + strings.TrimSpace(entry.Username) - } - syncLabel := "Sync manually when you choose Use Remote Sync." - if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave { - syncLabel = "Syncs automatically on open and save." - } - return appuiactions.SyncMenuBindingSummary{ - ProfileLabel: profile.Name, - CredentialLabel: credentialLabel, - SyncLabel: syncLabel, - OK: true, - } -}