diff --git a/main.go b/main.go index 70fc897..769dbbf 100644 --- a/main.go +++ b/main.go @@ -147,14 +147,34 @@ type recentRemoteRecord struct { } type uiPreferences struct { - GroupControlsHidden bool `json:"groupControlsHidden"` - LifecycleAdvancedHidden bool `json:"lifecycleAdvancedHidden"` - HistoryHidden bool `json:"historyHidden"` - DenseLayout bool `json:"denseLayout"` - StatusBannerMillis int `json:"statusBannerMillis,omitempty"` - AutofillNoticeMode string `json:"autofillNoticeMode,omitempty"` + GroupControlsHidden bool `json:"groupControlsHidden"` + LifecycleAdvancedHidden bool `json:"lifecycleAdvancedHidden"` + HistoryHidden bool `json:"historyHidden"` + DenseLayout bool `json:"denseLayout"` + StatusBannerMillis int `json:"statusBannerMillis,omitempty"` + AutofillNoticeMode string `json:"autofillNoticeMode,omitempty"` + DisplayDensity string `json:"displayDensity,omitempty"` + Contrast string `json:"contrast,omitempty"` + ReducedMotion bool `json:"reducedMotion,omitempty"` + KeyboardFocus string `json:"keyboardFocus,omitempty"` + AutofillPrivacy autofillPrivacySettings `json:"autofillPrivacy,omitempty"` } +type autofillPrivacySettings struct { + FirstFillApprovalMode string `json:"firstFillApprovalMode,omitempty"` + BrowserAllowlist []string `json:"browserAllowlist,omitempty"` + AppAllowlist []string `json:"appAllowlist,omitempty"` + PackageRules []string `json:"packageRules,omitempty"` +} + +type autofillFirstFillApprovalMode string + +const ( + autofillFirstFillApprovalAsk autofillFirstFillApprovalMode = "ask" + autofillFirstFillApprovalAllow autofillFirstFillApprovalMode = "allow" + autofillFirstFillApprovalBlock autofillFirstFillApprovalMode = "block" +) + type entriesSectionState struct { Path []string SearchQuery string @@ -177,227 +197,244 @@ const ( ) type ui struct { - mode string - theme *material.Theme - logoHorizontal paint.ImageOp - splashSquare paint.ImageOp - search widget.Editor - vaultPath widget.Editor - saveAsPath widget.Editor - remoteBaseURL widget.Editor - remotePath widget.Editor - remoteUsername widget.Editor - remotePassword widget.Editor - masterPassword widget.Editor - keyFilePath widget.Editor - apiTokenName widget.Editor - apiTokenClientName widget.Editor - apiTokenExpiresAt widget.Editor - apiPolicyOperation widget.Editor - apiPolicyPath widget.Editor - apiPolicyEntryID widget.Editor - securityCipher widget.Editor - securityKDF widget.Editor - entryID widget.Editor - entryTitle widget.Editor - entryUsername widget.Editor - entryPassword widget.Editor - entryURL widget.Editor - entryNotes widget.Editor - entryTags widget.Editor - entryPath widget.Editor - entryFields widget.Editor - customFieldKeys []widget.Editor - customFieldValues []widget.Editor - historyIndex widget.Editor - groupName widget.Editor - groupParentPath widget.Editor - passwordProfile widget.Editor - attachmentName widget.Editor - attachmentPath widget.Editor - exportAttachmentPath widget.Editor - list widget.List - groupList widget.List - detailList widget.List - apiPolicyList widget.List - lifecycleList widget.List - copyUser widget.Clickable - copyPass widget.Clickable - copyURL widget.Clickable - lockVault widget.Clickable - unlockVault widget.Clickable - createVault widget.Clickable - openVault widget.Clickable - saveVault widget.Clickable - saveAsVault widget.Clickable - openRemote widget.Clickable - changeMasterKey widget.Clickable - synchronizeVault widget.Clickable - toggleSyncMenu widget.Clickable - openAdvancedSync widget.Clickable - openSecuritySettings widget.Clickable - closeAdvancedSync widget.Clickable - closeSecuritySettings widget.Clickable - runAdvancedSync widget.Clickable - saveSecuritySettings widget.Clickable - editEntry widget.Clickable - cancelEdit widget.Clickable - pickVaultPath widget.Clickable - pickKeyFile widget.Clickable - pickSyncLocalPath widget.Clickable - clearVaultSelection widget.Clickable - clearRemoteSelection widget.Clickable - dismissBanner widget.Clickable - addEntry widget.Clickable - saveEntry widget.Clickable - duplicateEntry widget.Clickable - deleteEntry widget.Clickable - restoreEntry widget.Clickable - saveTemplate widget.Clickable - deleteTemplate widget.Clickable - instantiateTemplate widget.Clickable - addAttachment widget.Clickable - replaceAttachment widget.Clickable - removeAttachment widget.Clickable - exportAttachment widget.Clickable - restoreHistory widget.Clickable - generatePassword widget.Clickable - goToRootGroup widget.Clickable - goToParentGroup widget.Clickable - createGroup widget.Clickable - moveGroup widget.Clickable - renameGroup widget.Clickable - deleteGroup widget.Clickable - confirmDeleteGroup widget.Clickable - cancelDeleteGroup widget.Clickable - addCustomField widget.Clickable - toggleGroupControls widget.Clickable - toggleLifecycleAdvanced widget.Clickable - toggleHistory widget.Clickable - togglePasswordInline widget.Clickable - toggleSyncPassword widget.Clickable - setStatusBannerShort widget.Clickable - setStatusBannerStandard widget.Clickable - setStatusBannerLong widget.Clickable - showAllAutofillNotices widget.Clickable - showApprovalAutofillOnly widget.Clickable - hideAutofillNotices widget.Clickable - showEntries widget.Clickable - showTemplates widget.Clickable - showRecycle widget.Clickable - showAPITokens widget.Clickable - showAPIAudit widget.Clickable - showLocalLifecycle widget.Clickable - showRemoteLifecycle widget.Clickable - showSyncLocal widget.Clickable - showSyncRemote widget.Clickable - showSyncPull widget.Clickable - showSyncPush widget.Clickable - allowApproval widget.Clickable - denyApproval widget.Clickable - cancelApproval widget.Clickable - cancelLifecycleProgress widget.Clickable - retryLifecycleOpen widget.Clickable - approvalPermanent widget.Bool - rememberRemoteAuth widget.Bool - apiPolicyAllow widget.Bool - apiPolicyGroupScopeW widget.Bool - apiTokenDisabled widget.Bool - settingsGroupControls widget.Bool - settingsLifecycleAdvanced widget.Bool - settingsHistory widget.Bool - settingsDenseLayout widget.Bool - entryClicks []widget.Clickable - apiTokenClicks []widget.Clickable - apiPolicyRemoves []widget.Clickable - apiAuditClicks []widget.Clickable - apiAuditTokenFilters []widget.Clickable - apiAuditDecisionFilters []widget.Clickable - apiAuditOperationFilters []widget.Clickable - clearAPIAuditFilters widget.Clickable - historyClicks []widget.Clickable - attachmentClicks []widget.Clickable - breadcrumbs []widget.Clickable - groupClicks []widget.Clickable - recentVaultClicks []widget.Clickable - recentRemoteClicks []widget.Clickable - removeCustomFields []widget.Clickable - state appstate.State - visible []entry - currentPath []string - syncedPath []string - selectedHistoryIndex int - showPassword bool - generatedPasswordDraft bool - togglePassword widget.Clickable - copyAPITokenSecret widget.Clickable - issueAPIToken widget.Clickable - saveAPIToken widget.Clickable - rotateAPIToken widget.Clickable - disableAPIToken widget.Clickable - revokeAPIToken widget.Clickable - deleteAPIToken widget.Clickable - addAPIPolicyRule widget.Clickable - phoneSplit widget.Float - splitDrag gesture.Drag - splitBase float32 - splitStartY float32 - phoneSpan int - eyeIcon *widget.Icon - eyeOffIcon *widget.Icon - copyIcon *widget.Icon - expandMoreIcon *widget.Icon - expandLessIcon *widget.Icon - chevronDownIcon *widget.Icon - settingsIcon *widget.Icon - clipboardWriter clipboard.Writer - loadingMessage string - loadingActionLabel string - lifecycleMode string - syncSourceMode syncSourceMode - syncDirection syncDirection - syncLocalPath widget.Editor - syncRemoteBaseURL widget.Editor - syncRemotePath widget.Editor - syncRemoteUsername widget.Editor - syncRemotePassword widget.Editor - syncDialogOpen bool - syncMenuOpen bool - securityDialogOpen bool - showSyncPassword bool - keyboardFocus focusID - defaultSaveAsPath string - recentVaultsPath string - uiPreferencesPath string - recentRemotesPath string - autofillCachePath string - editingEntry bool - groupControlsHidden bool - lifecycleAdvancedHidden bool - historyHidden bool - denseLayout bool - statusBannerTTL time.Duration - autofillNoticePreference autofillNoticeMode - recentVaults []string - recentRemotes []recentRemoteRecord - recentVaultGroups map[string][]string - recentVaultUsedAt map[string]time.Time - entriesState entriesSectionState - deleteGroupPath []string - apiPolicyGroupScope bool - apiTokenSecret string - selectedAuditIndex int - statusExpiresAt time.Time - now func() time.Time - apiHost *api.Host - auditLog *apiaudit.Log - grpcAddress string - backgroundResults chan backgroundActionResult - backgroundActionSerial int - activeBackgroundAction int - lastLifecycleAction string - requestMasterPassFocus bool - invalidate func() + mode string + theme *material.Theme + logoHorizontal paint.ImageOp + splashSquare paint.ImageOp + search widget.Editor + vaultPath widget.Editor + saveAsPath widget.Editor + remoteBaseURL widget.Editor + remotePath widget.Editor + remoteUsername widget.Editor + remotePassword widget.Editor + masterPassword widget.Editor + keyFilePath widget.Editor + apiTokenName widget.Editor + apiTokenClientName widget.Editor + apiTokenExpiresAt widget.Editor + apiPolicyOperation widget.Editor + apiPolicyPath widget.Editor + apiPolicyEntryID widget.Editor + securityCipher widget.Editor + securityKDF widget.Editor + entryID widget.Editor + entryTitle widget.Editor + entryUsername widget.Editor + entryPassword widget.Editor + entryURL widget.Editor + entryNotes widget.Editor + entryTags widget.Editor + entryPath widget.Editor + entryFields widget.Editor + customFieldKeys []widget.Editor + customFieldValues []widget.Editor + historyIndex widget.Editor + groupName widget.Editor + groupParentPath widget.Editor + passwordProfile widget.Editor + attachmentName widget.Editor + attachmentPath widget.Editor + exportAttachmentPath widget.Editor + autofillBrowserAllowlist widget.Editor + autofillAppAllowlist widget.Editor + autofillPackageRules widget.Editor + list widget.List + groupList widget.List + detailList widget.List + apiPolicyList widget.List + lifecycleList widget.List + copyUser widget.Clickable + copyPass widget.Clickable + copyURL widget.Clickable + lockVault widget.Clickable + unlockVault widget.Clickable + createVault widget.Clickable + openVault widget.Clickable + saveVault widget.Clickable + saveAsVault widget.Clickable + openRemote widget.Clickable + changeMasterKey widget.Clickable + synchronizeVault widget.Clickable + toggleSyncMenu widget.Clickable + openAdvancedSync widget.Clickable + openSecuritySettings widget.Clickable + closeAdvancedSync widget.Clickable + closeSecuritySettings widget.Clickable + runAdvancedSync widget.Clickable + saveSecuritySettings widget.Clickable + settingsDensityDense widget.Clickable + settingsDensityComfortable widget.Clickable + settingsContrastStandard widget.Clickable + settingsContrastHigh widget.Clickable + settingsReducedMotionOff widget.Clickable + settingsReducedMotionOn widget.Clickable + settingsKeyboardFocusStandard widget.Clickable + settingsKeyboardFocusProminent widget.Clickable + editEntry widget.Clickable + cancelEdit widget.Clickable + pickVaultPath widget.Clickable + pickKeyFile widget.Clickable + pickSyncLocalPath widget.Clickable + clearVaultSelection widget.Clickable + clearRemoteSelection widget.Clickable + dismissBanner widget.Clickable + addEntry widget.Clickable + saveEntry widget.Clickable + duplicateEntry widget.Clickable + deleteEntry widget.Clickable + restoreEntry widget.Clickable + saveTemplate widget.Clickable + deleteTemplate widget.Clickable + instantiateTemplate widget.Clickable + addAttachment widget.Clickable + replaceAttachment widget.Clickable + removeAttachment widget.Clickable + exportAttachment widget.Clickable + restoreHistory widget.Clickable + generatePassword widget.Clickable + goToRootGroup widget.Clickable + goToParentGroup widget.Clickable + createGroup widget.Clickable + moveGroup widget.Clickable + renameGroup widget.Clickable + deleteGroup widget.Clickable + confirmDeleteGroup widget.Clickable + cancelDeleteGroup widget.Clickable + addCustomField widget.Clickable + toggleGroupControls widget.Clickable + toggleLifecycleAdvanced widget.Clickable + toggleHistory widget.Clickable + togglePasswordInline widget.Clickable + toggleSyncPassword widget.Clickable + setStatusBannerShort widget.Clickable + setStatusBannerStandard widget.Clickable + setStatusBannerLong widget.Clickable + showAllAutofillNotices widget.Clickable + showApprovalAutofillOnly widget.Clickable + hideAutofillNotices widget.Clickable + showEntries widget.Clickable + showTemplates widget.Clickable + showRecycle widget.Clickable + showAPITokens widget.Clickable + showAPIAudit widget.Clickable + showLocalLifecycle widget.Clickable + showRemoteLifecycle widget.Clickable + showSyncLocal widget.Clickable + showSyncRemote widget.Clickable + showSyncPull widget.Clickable + showSyncPush widget.Clickable + showAutofillApprovalAsk widget.Clickable + showAutofillApprovalAllow widget.Clickable + showAutofillApprovalBlock widget.Clickable + allowApproval widget.Clickable + denyApproval widget.Clickable + cancelApproval widget.Clickable + cancelLifecycleProgress widget.Clickable + retryLifecycleOpen widget.Clickable + approvalPermanent widget.Bool + rememberRemoteAuth widget.Bool + apiPolicyAllow widget.Bool + apiPolicyGroupScopeW widget.Bool + apiTokenDisabled widget.Bool + settingsGroupControls widget.Bool + settingsLifecycleAdvanced widget.Bool + settingsHistory widget.Bool + settingsDenseLayout widget.Bool + entryClicks []widget.Clickable + apiTokenClicks []widget.Clickable + apiPolicyRemoves []widget.Clickable + apiAuditClicks []widget.Clickable + apiAuditTokenFilters []widget.Clickable + apiAuditDecisionFilters []widget.Clickable + apiAuditOperationFilters []widget.Clickable + clearAPIAuditFilters widget.Clickable + historyClicks []widget.Clickable + attachmentClicks []widget.Clickable + breadcrumbs []widget.Clickable + groupClicks []widget.Clickable + recentVaultClicks []widget.Clickable + recentRemoteClicks []widget.Clickable + removeCustomFields []widget.Clickable + state appstate.State + visible []entry + currentPath []string + syncedPath []string + selectedHistoryIndex int + showPassword bool + generatedPasswordDraft bool + togglePassword widget.Clickable + copyAPITokenSecret widget.Clickable + issueAPIToken widget.Clickable + saveAPIToken widget.Clickable + rotateAPIToken widget.Clickable + disableAPIToken widget.Clickable + revokeAPIToken widget.Clickable + deleteAPIToken widget.Clickable + addAPIPolicyRule widget.Clickable + phoneSplit widget.Float + splitDrag gesture.Drag + splitBase float32 + splitStartY float32 + phoneSpan int + eyeIcon *widget.Icon + eyeOffIcon *widget.Icon + copyIcon *widget.Icon + expandMoreIcon *widget.Icon + expandLessIcon *widget.Icon + chevronDownIcon *widget.Icon + settingsIcon *widget.Icon + clipboardWriter clipboard.Writer + loadingMessage string + loadingActionLabel string + lifecycleMode string + syncSourceMode syncSourceMode + syncDirection syncDirection + syncLocalPath widget.Editor + syncRemoteBaseURL widget.Editor + syncRemotePath widget.Editor + syncRemoteUsername widget.Editor + syncRemotePassword widget.Editor + syncDialogOpen bool + syncMenuOpen bool + securityDialogOpen bool + showSyncPassword bool + keyboardFocus focusID + defaultSaveAsPath string + recentVaultsPath string + uiPreferencesPath string + recentRemotesPath string + autofillCachePath string + editingEntry bool + groupControlsHidden bool + lifecycleAdvancedHidden bool + historyHidden bool + denseLayout bool + statusBannerTTL time.Duration + autofillNoticePreference autofillNoticeMode + autofillFirstFillApprovalMode autofillFirstFillApprovalMode + accessibilityPrefs accessibilityPreferences + settingsDraft settingsDraft + recentVaults []string + recentRemotes []recentRemoteRecord + recentVaultGroups map[string][]string + recentVaultUsedAt map[string]time.Time + entriesState entriesSectionState + deleteGroupPath []string + apiPolicyGroupScope bool + apiTokenSecret string + selectedAuditIndex int + statusExpiresAt time.Time + now func() time.Time + apiHost *api.Host + auditLog *apiaudit.Log + grpcAddress string + backgroundResults chan backgroundActionResult + backgroundActionSerial int + activeBackgroundAction int + lastLifecycleAction string + requestMasterPassFocus bool + invalidate func() } type backgroundActionResult struct { @@ -449,43 +486,46 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) SingleLine: true, Submit: false, }, - vaultPath: widget.Editor{SingleLine: true, Submit: false}, - saveAsPath: widget.Editor{SingleLine: true, Submit: false}, - remoteBaseURL: widget.Editor{SingleLine: true, Submit: false}, - remotePath: widget.Editor{SingleLine: true, Submit: false}, - remoteUsername: widget.Editor{SingleLine: true, Submit: false}, - remotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•', InputHint: key.HintPassword}, - syncLocalPath: widget.Editor{SingleLine: true, Submit: false}, - syncRemoteBaseURL: widget.Editor{SingleLine: true, Submit: false}, - syncRemotePath: widget.Editor{SingleLine: true, Submit: false}, - syncRemoteUsername: widget.Editor{SingleLine: true, Submit: false}, - syncRemotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•', InputHint: key.HintPassword}, - masterPassword: widget.Editor{SingleLine: true, Submit: false, InputHint: key.HintPassword}, - keyFilePath: widget.Editor{SingleLine: true, Submit: false}, - apiTokenName: widget.Editor{SingleLine: true, Submit: false}, - apiTokenClientName: widget.Editor{SingleLine: true, Submit: false}, - apiTokenExpiresAt: widget.Editor{SingleLine: true, Submit: false}, - apiPolicyOperation: widget.Editor{SingleLine: true, Submit: false}, - apiPolicyPath: widget.Editor{SingleLine: true, Submit: false}, - apiPolicyEntryID: widget.Editor{SingleLine: true, Submit: false}, - securityCipher: widget.Editor{SingleLine: true, Submit: false}, - securityKDF: widget.Editor{SingleLine: true, Submit: false}, - entryID: widget.Editor{SingleLine: true, Submit: false}, - entryTitle: widget.Editor{SingleLine: true, Submit: false}, - entryUsername: widget.Editor{SingleLine: true, Submit: false}, - entryPassword: widget.Editor{SingleLine: true, Submit: false}, - entryURL: widget.Editor{SingleLine: true, Submit: false}, - entryNotes: widget.Editor{SingleLine: false, Submit: false}, - entryTags: widget.Editor{SingleLine: true, Submit: false}, - entryPath: widget.Editor{SingleLine: true, Submit: false}, - entryFields: widget.Editor{SingleLine: false, Submit: false}, - historyIndex: widget.Editor{SingleLine: true, Submit: false}, - groupName: widget.Editor{SingleLine: true, Submit: false}, - groupParentPath: widget.Editor{SingleLine: true, Submit: false}, - passwordProfile: widget.Editor{SingleLine: true, Submit: false}, - attachmentName: widget.Editor{SingleLine: true, Submit: false}, - attachmentPath: widget.Editor{SingleLine: true, Submit: false}, - exportAttachmentPath: widget.Editor{SingleLine: true, Submit: false}, + vaultPath: widget.Editor{SingleLine: true, Submit: false}, + saveAsPath: widget.Editor{SingleLine: true, Submit: false}, + remoteBaseURL: widget.Editor{SingleLine: true, Submit: false}, + remotePath: widget.Editor{SingleLine: true, Submit: false}, + remoteUsername: widget.Editor{SingleLine: true, Submit: false}, + remotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•', InputHint: key.HintPassword}, + syncLocalPath: widget.Editor{SingleLine: true, Submit: false}, + syncRemoteBaseURL: widget.Editor{SingleLine: true, Submit: false}, + syncRemotePath: widget.Editor{SingleLine: true, Submit: false}, + syncRemoteUsername: widget.Editor{SingleLine: true, Submit: false}, + syncRemotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•', InputHint: key.HintPassword}, + masterPassword: widget.Editor{SingleLine: true, Submit: false, InputHint: key.HintPassword}, + keyFilePath: widget.Editor{SingleLine: true, Submit: false}, + apiTokenName: widget.Editor{SingleLine: true, Submit: false}, + apiTokenClientName: widget.Editor{SingleLine: true, Submit: false}, + apiTokenExpiresAt: widget.Editor{SingleLine: true, Submit: false}, + apiPolicyOperation: widget.Editor{SingleLine: true, Submit: false}, + apiPolicyPath: widget.Editor{SingleLine: true, Submit: false}, + apiPolicyEntryID: widget.Editor{SingleLine: true, Submit: false}, + securityCipher: widget.Editor{SingleLine: true, Submit: false}, + securityKDF: widget.Editor{SingleLine: true, Submit: false}, + entryID: widget.Editor{SingleLine: true, Submit: false}, + entryTitle: widget.Editor{SingleLine: true, Submit: false}, + entryUsername: widget.Editor{SingleLine: true, Submit: false}, + entryPassword: widget.Editor{SingleLine: true, Submit: false}, + entryURL: widget.Editor{SingleLine: true, Submit: false}, + entryNotes: widget.Editor{SingleLine: false, Submit: false}, + entryTags: widget.Editor{SingleLine: true, Submit: false}, + entryPath: widget.Editor{SingleLine: true, Submit: false}, + entryFields: widget.Editor{SingleLine: false, Submit: false}, + historyIndex: widget.Editor{SingleLine: true, Submit: false}, + groupName: widget.Editor{SingleLine: true, Submit: false}, + groupParentPath: widget.Editor{SingleLine: true, Submit: false}, + passwordProfile: widget.Editor{SingleLine: true, Submit: false}, + attachmentName: widget.Editor{SingleLine: true, Submit: false}, + attachmentPath: widget.Editor{SingleLine: true, Submit: false}, + exportAttachmentPath: widget.Editor{SingleLine: true, Submit: false}, + autofillBrowserAllowlist: widget.Editor{SingleLine: false, Submit: false}, + autofillAppAllowlist: widget.Editor{SingleLine: false, Submit: false}, + autofillPackageRules: widget.Editor{SingleLine: false, Submit: false}, list: widget.List{ List: layout.List{Axis: layout.Vertical}, }, @@ -501,27 +541,29 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) lifecycleList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, - state: appstate.State{}, - selectedHistoryIndex: -1, - selectedAuditIndex: -1, - lifecycleMode: "local", - defaultSaveAsPath: paths.DefaultSaveAsPath, - recentVaultsPath: paths.RecentVaultsPath, - uiPreferencesPath: paths.UIPreferencesPath, - recentRemotesPath: paths.RecentRemotesPath, - autofillCachePath: paths.AutofillCachePath, - recentVaultGroups: map[string][]string{}, - recentVaultUsedAt: map[string]time.Time{}, - lifecycleAdvancedHidden: true, - historyHidden: true, - statusBannerTTL: statusBannerDuration, - now: time.Now, - syncSourceMode: syncSourceLocal, - syncDirection: syncDirectionPull, - apiPolicyGroupScope: true, - autofillNoticePreference: autofillNoticeAll, - backgroundResults: make(chan backgroundActionResult, 8), - requestMasterPassFocus: true, + state: appstate.State{}, + selectedHistoryIndex: -1, + selectedAuditIndex: -1, + lifecycleMode: "local", + defaultSaveAsPath: paths.DefaultSaveAsPath, + recentVaultsPath: paths.RecentVaultsPath, + uiPreferencesPath: paths.UIPreferencesPath, + recentRemotesPath: paths.RecentRemotesPath, + autofillCachePath: paths.AutofillCachePath, + recentVaultGroups: map[string][]string{}, + recentVaultUsedAt: map[string]time.Time{}, + lifecycleAdvancedHidden: true, + historyHidden: true, + statusBannerTTL: statusBannerDuration, + accessibilityPrefs: defaultAccessibilityPreferences(), + autofillFirstFillApprovalMode: autofillFirstFillApprovalAsk, + now: time.Now, + syncSourceMode: syncSourceLocal, + syncDirection: syncDirectionPull, + apiPolicyGroupScope: true, + autofillNoticePreference: autofillNoticeAll, + backgroundResults: make(chan backgroundActionResult, 8), + requestMasterPassFocus: true, } u.apiPolicyAllow.Value = true u.apiPolicyGroupScopeW.Value = true @@ -544,6 +586,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) u.restoreStartupLifecycleTarget() u.loadUIPreferences() u.loadSettingsFormFromPreferences() + u.loadSettingsDraft() u.filter() u.syncAutofillCache() return u @@ -1033,20 +1076,6 @@ func (u *ui) loadSecuritySettingsFromSession() { u.securityKDF.SetText(settings.KDF) } -func (u *ui) saveSecuritySettingsAction() error { - settings := vault.SecuritySettings{ - Cipher: strings.TrimSpace(u.securityCipher.Text()), - KDF: strings.TrimSpace(u.securityKDF.Text()), - } - if err := u.state.ConfigureSecurity(settings); err != nil { - return err - } - u.applySettingsFormToPreferences() - u.saveUIPreferences() - u.securityDialogOpen = false - return nil -} - func (u *ui) clearMasterPassword() { u.masterPassword.SetText("") } @@ -1322,6 +1351,22 @@ func (u *ui) loadUIPreferences() { u.denseLayout = prefs.DenseLayout u.statusBannerTTL = normalizedStatusBannerTTL(prefs.StatusBannerMillis) u.autofillNoticePreference = normalizedAutofillNoticeMode(prefs.AutofillNoticeMode) + displayDensity := strings.TrimSpace(prefs.DisplayDensity) + if displayDensity == "" { + displayDensity = displayDensityForDenseLayout(prefs.DenseLayout) + } + u.applyAccessibilityPreferences(accessibilityPreferences{ + DisplayDensity: displayDensity, + Contrast: prefs.Contrast, + ReducedMotion: prefs.ReducedMotion, + KeyboardFocus: prefs.KeyboardFocus, + }) + if mode := parseAutofillFirstFillApprovalMode(prefs.AutofillPrivacy.FirstFillApprovalMode); mode != "" { + u.autofillFirstFillApprovalMode = mode + } + u.autofillBrowserAllowlist.SetText(joinAutofillPrivacyLines(prefs.AutofillPrivacy.BrowserAllowlist)) + u.autofillAppAllowlist.SetText(joinAutofillPrivacyLines(prefs.AutofillPrivacy.AppAllowlist)) + u.autofillPackageRules.SetText(joinAutofillPrivacyLines(prefs.AutofillPrivacy.PackageRules)) } func (u *ui) saveUIPreferences() { @@ -1338,6 +1383,16 @@ func (u *ui) saveUIPreferences() { DenseLayout: u.denseLayout, StatusBannerMillis: int(u.statusBannerTTL / time.Millisecond), AutofillNoticeMode: string(u.autofillNoticePreference), + DisplayDensity: u.accessibilityPrefs.DisplayDensity, + Contrast: u.accessibilityPrefs.Contrast, + ReducedMotion: u.accessibilityPrefs.ReducedMotion, + KeyboardFocus: u.accessibilityPrefs.KeyboardFocus, + AutofillPrivacy: autofillPrivacySettings{ + FirstFillApprovalMode: string(u.autofillFirstFillApprovalMode), + BrowserAllowlist: autofillPrivacyLines(u.autofillBrowserAllowlist.Text()), + AppAllowlist: autofillPrivacyLines(u.autofillAppAllowlist.Text()), + PackageRules: autofillPrivacyLines(u.autofillPackageRules.Text()), + }, }, "", " ") if err != nil { return @@ -1381,6 +1436,57 @@ func normalizedAutofillNoticeMode(value string) autofillNoticeMode { } } +func parseAutofillFirstFillApprovalMode(raw string) autofillFirstFillApprovalMode { + switch autofillFirstFillApprovalMode(strings.TrimSpace(raw)) { + case autofillFirstFillApprovalAsk, autofillFirstFillApprovalAllow, autofillFirstFillApprovalBlock: + return autofillFirstFillApprovalMode(strings.TrimSpace(raw)) + default: + return "" + } +} + +func autofillPrivacyLines(text string) []string { + lines := strings.Split(text, "\n") + result := make([]string, 0, len(lines)) + seen := make(map[string]struct{}, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if _, ok := seen[line]; ok { + continue + } + seen[line] = struct{}{} + result = append(result, line) + } + return result +} + +func joinAutofillPrivacyLines(lines []string) string { + if len(lines) == 0 { + return "" + } + return strings.Join(autofillPrivacyLines(strings.Join(lines, "\n")), "\n") +} + +func (u *ui) autofillRuleCount() int { + return len(autofillPrivacyLines(u.autofillBrowserAllowlist.Text())) + + len(autofillPrivacyLines(u.autofillAppAllowlist.Text())) + + len(autofillPrivacyLines(u.autofillPackageRules.Text())) +} + +func (u *ui) autofillFirstFillApprovalSummary() string { + switch u.autofillFirstFillApprovalMode { + case autofillFirstFillApprovalAllow: + return "New apps and packages can fill immediately until a persistent rule is created." + case autofillFirstFillApprovalBlock: + return "New apps and packages stay blocked until you add an allowlist entry or a package rule." + default: + return "KeePassGO asks before the first fill into a newly seen app or package." + } +} + func (u *ui) setStatusBannerTTL(value time.Duration) { u.statusBannerTTL = normalizedStatusBannerTTL(int(value / time.Millisecond)) u.saveUIPreferences() @@ -1761,8 +1867,7 @@ func (u *ui) armDeleteCurrentGroupAction() { u.syncCurrentPath() u.deleteGroupPath = append([]string(nil), u.currentPath...) u.state.ErrorMessage = "" - u.state.StatusMessage = fmt.Sprintf("Confirm deleting empty group %q.", strings.Join(u.displayPath(), " / ")) - u.statusExpiresAt = u.now().Add(u.statusBannerTTL) + u.showStatusMessage(fmt.Sprintf("Confirm deleting empty group %q.", strings.Join(u.displayPath(), " / "))) } func (u *ui) runAction(label string, action func() error) { @@ -1788,8 +1893,7 @@ func (u *ui) runAction(label string, action func() error) { u.statusExpiresAt = time.Time{} return } - u.state.StatusMessage = label + " complete" - u.statusExpiresAt = u.now().Add(u.statusBannerTTL) + u.showStatusMessage(label + " complete") } func (u *ui) runBackgroundAction(label string, prepare func() (func() error, error)) { @@ -1847,8 +1951,7 @@ func (u *ui) applyBackgroundResult(result backgroundActionResult) { u.statusExpiresAt = time.Time{} return } - u.state.StatusMessage = result.label + " complete" - u.statusExpiresAt = u.now().Add(u.statusBannerTTL) + u.showStatusMessage(result.label + " complete") } func (u *ui) cancelLifecycleBusyState() { @@ -2528,6 +2631,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.openSecuritySettings.Clicked(gtx) { u.loadSecuritySettingsFromSession() u.loadSettingsFormFromPreferences() + u.loadSettingsDraft() u.securityDialogOpen = true } for u.setStatusBannerShort.Clicked(gtx) { @@ -2559,7 +2663,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.runAction("advanced synchronize vault", u.advancedSyncAction) } for u.saveSecuritySettings.Clicked(gtx) { - u.runAction("save security settings", u.saveSecuritySettingsAction) + u.runAction("save settings", u.saveSecuritySettingsAction) } for u.unlockVault.Clicked(gtx) { u.startUnlockAction() @@ -2624,6 +2728,15 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.showSyncPush.Clicked(gtx) { u.syncDirection = syncDirectionPush } + for u.showAutofillApprovalAsk.Clicked(gtx) { + u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAsk + } + for u.showAutofillApprovalAllow.Clicked(gtx) { + u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAllow + } + for u.showAutofillApprovalBlock.Clicked(gtx) { + u.autofillFirstFillApprovalMode = autofillFirstFillApprovalBlock + } for u.allowApproval.Clicked(gtx) { u.runAction("allow API request", func() error { outcome := apiapproval.OutcomeAllowOnce @@ -3049,9 +3162,55 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions { lbl.Color = accentColor return lbl.Layout(gtx) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "ACCESSIBILITY") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.settingsPreferenceCard(gtx, "Display Density", "Adjust editor height and control spacing for denser or roomier forms.", func(gtx layout.Context) layout.Dimensions { + return u.settingsChoiceRow( + gtx, + choiceSpec{Click: &u.settingsDensityDense, Label: "Dense", Active: u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense}, + choiceSpec{Click: &u.settingsDensityComfortable, Label: "Comfortable", Active: u.settingsDraft.Accessibility.DisplayDensity == displayDensityComfortable}, + ) + }) + }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(14), "Choose how KeePassGO remembers UI layout behavior, tune noncritical feedback, and set the KDBX security defaults used for new or future saves.") + return u.settingsPreferenceCard(gtx, "Contrast", "Increase focus and selection contrast without changing unrelated vault behavior.", func(gtx layout.Context) layout.Dimensions { + return u.settingsChoiceRow( + gtx, + choiceSpec{Click: &u.settingsContrastStandard, Label: "Standard", Active: u.settingsDraft.Accessibility.Contrast == contrastStandard}, + choiceSpec{Click: &u.settingsContrastHigh, Label: "High Contrast", Active: u.settingsDraft.Accessibility.Contrast == contrastHigh}, + ) + }) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.settingsPreferenceCard(gtx, "Reduced Motion", "Keep transient status toasts steady instead of auto-dismissing after a short timeout.", func(gtx layout.Context) layout.Dimensions { + return u.settingsChoiceRow( + gtx, + choiceSpec{Click: &u.settingsReducedMotionOff, Label: "Off", Active: !u.settingsDraft.Accessibility.ReducedMotion}, + choiceSpec{Click: &u.settingsReducedMotionOn, Label: "On", Active: u.settingsDraft.Accessibility.ReducedMotion}, + ) + }) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.settingsPreferenceCard(gtx, "Keyboard Focus", "Strengthen the visible focus ring and focused selection treatment for keyboard-first navigation.", func(gtx layout.Context) layout.Dimensions { + return u.settingsChoiceRow( + gtx, + choiceSpec{Click: &u.settingsKeyboardFocusStandard, Label: "Standard", Active: u.settingsDraft.Accessibility.KeyboardFocus == keyboardFocusStandard}, + choiceSpec{Click: &u.settingsKeyboardFocusProminent, Label: "Prominent", Active: u.settingsDraft.Accessibility.KeyboardFocus == keyboardFocusProminent}, + ) + }) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(14), "Choose how KeePassGO remembers UI layout behavior, tunes noncritical feedback, and sets the KDBX security defaults used for new or future saves.") lbl.Color = mutedColor return lbl.Layout(gtx) }), @@ -3078,12 +3237,6 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions { check := material.CheckBox(u.theme, &u.settingsDenseLayout, "Use dense entry layout") return check.Layout(gtx) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "Dense layout reduces list and detail spacing. Leave it unchecked for the default comfortable spacing.") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(16), "Vault Security") @@ -3153,6 +3306,52 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions { ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(syncDialogSectionLabel(u.theme, "Privacy")), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return settingsSummaryCard(gtx, u.theme, "PRIVACY PLAN", "Use first-fill approval plus browser/app rules to keep autofill constrained to trusted targets.") + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(14), "First-fill approval") + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), u.autofillFirstFillApprovalSummary()) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncChoiceButton(gtx, u.theme, &u.showAutofillApprovalAsk, "Ask First", u.autofillFirstFillApprovalMode == autofillFirstFillApprovalAsk) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncChoiceButton(gtx, u.theme, &u.showAutofillApprovalAllow, "Allow First Fill", u.autofillFirstFillApprovalMode == autofillFirstFillApprovalAllow) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncChoiceButton(gtx, u.theme, &u.showAutofillApprovalBlock, "Block Until Allowed", u.autofillFirstFillApprovalMode == autofillFirstFillApprovalBlock) + }), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), fmt.Sprintf("%d autofill rule entries configured across browsers, apps, and package-specific overrides.", u.autofillRuleCount())) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(labeledEditorHelp(u.theme, "Browser allowlist", "One origin or hostname per line for trusted browser surfaces.", &u.autofillBrowserAllowlist, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(labeledEditorHelp(u.theme, "App and package allowlist", "One Android package name or trusted app identifier per line.", &u.autofillAppAllowlist, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(labeledEditorHelp(u.theme, "Package rules", "One rule per line, for example `com.android.chrome=hostname` or `org.keepassgo.browser=view-id`.", &u.autofillPackageRules, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -3394,7 +3593,7 @@ func (u *ui) syncPasswordField(gtx layout.Context) layout.Dimensions { } return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - return outlinedFieldState(gtx, false, field) + return u.outlinedFieldState(gtx, false, field) }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -3640,7 +3839,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { if u.mode == "phone" { gtx.Constraints.Min.X = gtx.Constraints.Max.X } - return outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions { + return u.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 @@ -3781,20 +3980,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item inset, titleSize, metaSize, urlSize, pathSize, dividerGap := u.entryRowMetrics() selected := item.ID == u.state.SelectedEntryID focused := u.isFocused(listFocusID(idx)) - titleColor := accentColor - metaColor := color.NRGBA{R: 61, G: 60, B: 56, A: 255} - secondaryColor := mutedColor - dividerColor := color.NRGBA{R: 225, G: 219, B: 210, A: 255} - if selected { - titleColor = color.NRGBA{R: 19, G: 57, B: 43, A: 255} - metaColor = color.NRGBA{R: 31, G: 53, B: 44, A: 255} - secondaryColor = color.NRGBA{R: 72, G: 88, B: 80, A: 255} - dividerColor = color.NRGBA{R: 173, G: 196, B: 184, A: 255} - } else if focused { - metaColor = color.NRGBA{R: 49, G: 74, B: 63, A: 255} - secondaryColor = color.NRGBA{R: 86, G: 102, B: 95, A: 255} - dividerColor = color.NRGBA{R: 190, G: 208, B: 199, A: 255} - } + rowColors := u.listRowColors(selected, focused, u.state.Section == appstate.SectionRecycleBin) row := func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(inset).Layout(gtx, func(gtx layout.Context) layout.Dimensions { showPath := strings.TrimSpace(u.search.Text()) != "" || len(u.displayPath()) == 0 || u.state.Section == appstate.SectionRecycleBin @@ -3804,7 +3990,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, titleSize, item.Title) - lbl.Color = titleColor + lbl.Color = rowColors.Title return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -3818,7 +4004,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item return layout.Dimensions{} } lbl := material.Label(u.theme, metaSize, item.Username) - lbl.Color = metaColor + lbl.Color = rowColors.Meta return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -3832,7 +4018,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item return layout.Dimensions{} } lbl := material.Label(u.theme, urlSize, item.URL) - lbl.Color = secondaryColor + lbl.Color = rowColors.Secondary return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -3846,7 +4032,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item return layout.Dimensions{} } lbl := material.Label(u.theme, pathSize, pathText) - lbl.Color = secondaryColor + lbl.Color = rowColors.Secondary return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: dividerGap}.Layout), @@ -3855,7 +4041,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item if w < 1 { w = 1 } - paint.FillShape(gtx.Ops, dividerColor, clip.Rect{Max: image.Pt(w, 1)}.Op()) + paint.FillShape(gtx.Ops, rowColors.Divider, clip.Rect{Max: image.Pt(w, 1)}.Op()) return layout.Dimensions{Size: image.Pt(w, 1)} }), ) @@ -3871,18 +4057,8 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item if size.Y == 0 { size.Y = gtx.Constraints.Max.Y } - fillColor := color.NRGBA{R: 212, G: 228, B: 220, A: 255} - edgeColor := color.NRGBA{R: 46, G: 106, B: 82, A: 255} - if u.state.Section == appstate.SectionRecycleBin { - fillColor = color.NRGBA{R: 244, G: 229, B: 219, A: 255} - edgeColor = color.NRGBA{R: 133, G: 65, B: 41, A: 255} - } - if focused && !selected { - fillColor = color.NRGBA{R: 231, G: 239, B: 235, A: 255} - edgeColor = color.NRGBA{R: 69, G: 118, B: 97, A: 255} - } - paint.FillShape(gtx.Ops, fillColor, clip.Rect{Max: size}.Op()) - paint.FillShape(gtx.Ops, edgeColor, clip.Rect{Max: image.Pt(5, size.Y)}.Op()) + paint.FillShape(gtx.Ops, rowColors.Fill, clip.Rect{Max: size}.Op()) + paint.FillShape(gtx.Ops, rowColors.Edge, clip.Rect{Max: image.Pt(5, size.Y)}.Op()) return layout.Dimensions{Size: size} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { @@ -4715,7 +4891,7 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { u.filter() } btn := material.Button(u.theme, &u.breadcrumbs[index], label) - btn.Background, btn.Color = buttonFocusColors(u.isFocused(breadcrumbFocusID(index))) + btn.Background, btn.Color = buttonFocusColors(u.accessibilityPrefs, u.isFocused(breadcrumbFocusID(index))) btn.TextSize = unit.Sp(11) if u.mode == "phone" { btn.TextSize = unit.Sp(9) @@ -4982,8 +5158,8 @@ func emptyStatePanel(gtx layout.Context, th *material.Theme, state emptyState) l }) } -func outlinedFieldState(gtx layout.Context, focused bool, w layout.Widget) layout.Dimensions { - appearance := fieldFocusAppearance(gtx.Metric, focused) +func outlinedFieldStateWithPrefs(gtx layout.Context, prefs accessibilityPreferences, focused bool, w layout.Widget) layout.Dimensions { + appearance := fieldFocusAppearance(gtx.Metric, prefs, focused) size := gtx.Constraints.Min if size.X == 0 { size.X = gtx.Constraints.Max.X @@ -5025,9 +5201,13 @@ func outlinedFieldState(gtx layout.Context, focused bool, w layout.Widget) layou ) } +func (u *ui) outlinedFieldState(gtx layout.Context, focused bool, w layout.Widget) layout.Dimensions { + return outlinedFieldStateWithPrefs(gtx, u.accessibilityPrefs, focused, w) +} + func tonedButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string) layout.Dimensions { btn := material.Button(th, click, label) - btn.Background, btn.Color = buttonFocusColors(false) + btn.Background, btn.Color = buttonFocusColors(defaultAccessibilityPreferences(), false) btn.CornerRadius = unit.Dp(10) btn.TextSize = unit.Sp(15) if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(460)) { diff --git a/main_test.go b/main_test.go index 2e2c277..da591cf 100644 --- a/main_test.go +++ b/main_test.go @@ -2671,9 +2671,9 @@ func TestUIAccessibilityLabelsDescribeFocusableControls(t *testing.T) { 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) + lo := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, defaultAccessibilityPreferences(), true) + hi := fieldFocusAppearance(unit.Metric{PxPerDp: 2.5, PxPerSp: 2.5}, defaultAccessibilityPreferences(), true) + unfocused := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, defaultAccessibilityPreferences(), false) if got := lo.MinHeight; got != 44 { t.Fatalf("fieldFocusAppearance(low).MinHeight = %d, want 44", got) @@ -2935,6 +2935,26 @@ func TestUIStatusToastExpiresAfterConfiguredTimeout(t *testing.T) { } } +func TestUIReducedMotionKeepsStatusToastVisible(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.March, 29, 12, 0, 0, 0, time.UTC) + u := newUIWithModel("desktop", vault.Model{}) + u.now = func() time.Time { return now } + u.statusBannerTTL = statusBannerLong + u.applyAccessibilityPreferences(accessibilityPreferences{ReducedMotion: true}) + + u.showStatusMessage("synchronize vault complete") + if !u.statusExpiresAt.IsZero() { + t.Fatalf("statusExpiresAt with reduced motion = %v, want zero", u.statusExpiresAt) + } + + now = now.Add(statusBannerLong * 2) + if got := u.statusToastSurface(); got.Kind != bannerStatus || got.Message != "synchronize vault complete" { + t.Fatalf("statusToastSurface() with reduced motion = %#v, want persistent status toast", got) + } +} + func TestUIAutofillStatusSurfaceUsesPendingApproval(t *testing.T) { t.Parallel() @@ -3538,6 +3558,60 @@ func TestUIDenseLayoutPreferencePersists(t *testing.T) { } } +func TestUIAccessibilityPreferencesPersist(t *testing.T) { + t.Parallel() + + configPath := filepath.Join(t.TempDir(), "ui-prefs.json") + + first := newUIWithSession("desktop", &session.Manager{}) + first.uiPreferencesPath = configPath + first.applyAccessibilityPreferences(accessibilityPreferences{ + DisplayDensity: displayDensityComfortable, + Contrast: contrastHigh, + ReducedMotion: true, + KeyboardFocus: keyboardFocusProminent, + }) + first.saveUIPreferences() + + second := newUIWithSession("desktop", &session.Manager{}) + second.uiPreferencesPath = configPath + second.loadUIPreferences() + + if second.denseLayout { + t.Fatal("denseLayout after reload = true, want comfortable layout preference") + } + if got := second.accessibilityPrefs; got != (accessibilityPreferences{ + DisplayDensity: displayDensityComfortable, + Contrast: contrastHigh, + ReducedMotion: true, + KeyboardFocus: keyboardFocusProminent, + }) { + t.Fatalf("accessibilityPrefs after reload = %#v, want comfortable/high/reduced/prominent", got) + } +} + +func TestFieldFocusAppearanceUsesAccessibilityPreferences(t *testing.T) { + t.Parallel() + + metric := unit.Metric{PxPerDp: 1, PxPerSp: 1} + base := fieldFocusAppearance(metric, defaultAccessibilityPreferences(), true) + comfortable := fieldFocusAppearance(metric, accessibilityPreferences{ + DisplayDensity: displayDensityComfortable, + Contrast: contrastHigh, + KeyboardFocus: keyboardFocusProminent, + }, true) + + if comfortable.MinHeight <= base.MinHeight { + t.Fatalf("fieldFocusAppearance(comfortable).MinHeight = %d, want > %d", comfortable.MinHeight, base.MinHeight) + } + if comfortable.OutlineWidth <= base.OutlineWidth { + t.Fatalf("fieldFocusAppearance(prominent).OutlineWidth = %d, want > %d", comfortable.OutlineWidth, base.OutlineWidth) + } + if comfortable.OutlineColor.A <= base.OutlineColor.A { + t.Fatalf("fieldFocusAppearance(high contrast).OutlineColor.A = %d, want > %d", comfortable.OutlineColor.A, base.OutlineColor.A) + } +} + func TestUIEntryRowMetricsUseDenseLayout(t *testing.T) { t.Parallel() diff --git a/ui_accessibility.go b/ui_accessibility.go index 5ee2289..4165fdf 100644 --- a/ui_accessibility.go +++ b/ui_accessibility.go @@ -19,26 +19,49 @@ type focusAppearance struct { MinHeight int } -func fieldFocusAppearance(metric unit.Metric, focused bool) focusAppearance { +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(focused bool) (background color.NRGBA, text color.NRGBA) { +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 } diff --git a/ui_forms.go b/ui_forms.go index 0fb6347..cd340cc 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -969,15 +969,15 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions { return sectionCard(gtx, u.theme, "BASICS", "Core entry identity and navigation fields.", func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(labeledEditorWithFocus(u.theme, "Title", &u.entryTitle, false, u.isFocused(detailFocusID(detailFieldTitle)))), + layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Title", &u.entryTitle, false, u.isFocused(detailFocusID(detailFieldTitle)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditorWithFocus(u.theme, "Username", &u.entryUsername, false, u.isFocused(detailFocusID(detailFieldUsername)))), + layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Username", &u.entryUsername, false, u.isFocused(detailFocusID(detailFieldUsername)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditorWithFocus(u.theme, "URL", &u.entryURL, false, u.isFocused(detailFocusID(detailFieldURL)))), + layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "URL", &u.entryURL, false, u.isFocused(detailFocusID(detailFieldURL)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditorWithFocus(u.theme, "Path", &u.entryPath, false, u.isFocused(detailFocusID(detailFieldPath)))), + layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Path", &u.entryPath, false, u.isFocused(detailFocusID(detailFieldPath)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditorWithFocus(u.theme, "Tags", &u.entryTags, false, u.isFocused(detailFocusID(detailFieldTags)))), + layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Tags", &u.entryTags, false, u.isFocused(detailFocusID(detailFieldTags)))), ) }) }), @@ -985,9 +985,9 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions { return sectionCard(gtx, u.theme, "PASSWORD", "Generate, review, and keep track of password changes before you save.", func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(labeledEditorWithFocus(u.theme, "Password", &u.entryPassword, true, u.isFocused(detailFocusID(detailFieldPassword)))), + layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Password", &u.entryPassword, true, u.isFocused(detailFocusID(detailFieldPassword)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditorWithFocus(u.theme, "Password Profile", &u.passwordProfile, false, u.isFocused(detailFocusID(detailFieldPasswordProfile)))), + layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Password Profile", &u.passwordProfile, false, u.isFocused(detailFocusID(detailFieldPasswordProfile)))), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(11), u.passwordProfileOptionsText()) @@ -1057,7 +1057,7 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return sectionCard(gtx, u.theme, "NOTES", "Long-form context for this entry.", func(gtx layout.Context) layout.Dimensions { - return labeledMultilineEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)), unit.Dp(108))(gtx) + return labeledMultilineEditorWithFocus(u.theme, u.accessibilityPrefs, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)), unit.Dp(108))(gtx) }) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), @@ -1065,7 +1065,7 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return sectionCard(gtx, u.theme, "HISTORY", "Pick a saved version index to restore into the current entry.", func(gtx layout.Context) layout.Dimensions { - return labeledEditorWithFocus(u.theme, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))(gtx) + return labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))(gtx) }) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), @@ -1142,17 +1142,17 @@ 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) + return labeledEditorWithFocus(th, defaultAccessibilityPreferences(), label, editor, sensitive, false) } func labeledEditorHelp(th *material.Theme, label, help string, editor *widget.Editor, sensitive bool) layout.Widget { - return labeledEditorHelpFocus(th, label, help, editor, sensitive, false) + return labeledEditorHelpFocus(th, defaultAccessibilityPreferences(), label, help, editor, sensitive, false) } -func labeledEditorHelpFocus(th *material.Theme, label, help string, editor *widget.Editor, sensitive bool, focused bool) layout.Widget { +func labeledEditorHelpFocus(th *material.Theme, prefs accessibilityPreferences, label, help 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(labeledEditorWithFocus(th, label, editor, sensitive, focused)), + layout.Rigid(labeledEditorWithFocus(th, prefs, label, editor, sensitive, focused)), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(th, unit.Sp(11), help) @@ -1276,7 +1276,7 @@ func (u *ui) masterPasswordField(gtx layout.Context, help string) layout.Dimensi return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return outlinedFieldState(gtx, false, func(gtx layout.Context) layout.Dimensions { + return u.outlinedFieldState(gtx, false, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { @@ -1313,6 +1313,7 @@ func (u *ui) masterPasswordField(gtx layout.Context, help string) layout.Dimensi func labeledEditorWithFocus( th *material.Theme, + prefs accessibilityPreferences, label string, editor *widget.Editor, sensitive bool, @@ -1326,7 +1327,7 @@ func labeledEditorWithFocus( return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return outlinedFieldState(gtx, focused, func(gtx layout.Context) layout.Dimensions { + return outlinedFieldStateWithPrefs(gtx, prefs, focused, func(gtx layout.Context) layout.Dimensions { mask := editor.Mask if sensitive { editor.Mask = '•' @@ -1343,6 +1344,7 @@ func labeledEditorWithFocus( func labeledMultilineEditorWithFocus( th *material.Theme, + prefs accessibilityPreferences, label string, editor *widget.Editor, sensitive bool, @@ -1357,7 +1359,7 @@ func labeledMultilineEditorWithFocus( return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return outlinedFieldState(gtx, focused, func(gtx layout.Context) layout.Dimensions { + return outlinedFieldStateWithPrefs(gtx, prefs, focused, func(gtx layout.Context) layout.Dimensions { mask := editor.Mask if sensitive { editor.Mask = '•' diff --git a/ui_preferences.go b/ui_preferences.go new file mode 100644 index 0000000..55b31e5 --- /dev/null +++ b/ui_preferences.go @@ -0,0 +1,209 @@ +package main + +import ( + "image/color" + "strings" + "time" + + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + "git.julianfamily.org/keepassgo/vault" +) + +const ( + displayDensityDense = "dense" + displayDensityComfortable = "comfortable" + + contrastStandard = "standard" + contrastHigh = "high" + + keyboardFocusStandard = "standard" + keyboardFocusProminent = "prominent" +) + +type accessibilityPreferences struct { + DisplayDensity string + Contrast string + ReducedMotion bool + KeyboardFocus string +} + +type settingsDraft struct { + Accessibility accessibilityPreferences +} + +type choiceSpec struct { + Click *widget.Clickable + Label string + Active bool +} + +func defaultAccessibilityPreferences() accessibilityPreferences { + return accessibilityPreferences{ + DisplayDensity: displayDensityForDenseLayout(true), + 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 +} + +func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) { + normalized := normalizeAccessibilityPreferences(prefs) + u.denseLayout = normalized.DisplayDensity == displayDensityDense + u.accessibilityPrefs = normalized +} + +func (u *ui) loadSettingsDraft() { + u.settingsDraft = settingsDraft{ + Accessibility: accessibilityPreferences{ + DisplayDensity: displayDensityForDenseLayout(u.denseLayout), + Contrast: u.accessibilityPrefs.Contrast, + ReducedMotion: u.accessibilityPrefs.ReducedMotion, + KeyboardFocus: u.accessibilityPrefs.KeyboardFocus, + }, + } +} + +func (u *ui) saveSecuritySettingsAction() error { + settings := vault.SecuritySettings{ + Cipher: strings.TrimSpace(u.securityCipher.Text()), + KDF: strings.TrimSpace(u.securityKDF.Text()), + } + if err := u.state.ConfigureSecurity(settings); err != nil { + return err + } + if u.settingsDraft.Accessibility.DisplayDensity == displayDensityForDenseLayout(u.denseLayout) { + u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value) + } + u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense + u.applySettingsFormToPreferences() + u.applyAccessibilityPreferences(u.settingsDraft.Accessibility) + u.saveUIPreferences() + u.securityDialogOpen = false + return nil +} + +func (u *ui) showStatusMessage(message string) { + u.state.StatusMessage = message + if u.accessibilityPrefs.ReducedMotion { + u.statusExpiresAt = time.Time{} + return + } + u.statusExpiresAt = u.now().Add(u.statusBannerTTL) +} + +func (u *ui) settingsPreferenceCard(gtx layout.Context, title, detail string, body layout.Widget) layout.Dimensions { + return sectionCard(gtx, u.theme, title, detail, body) +} + +func settingsSummaryCard(gtx layout.Context, th *material.Theme, title, body string) layout.Dimensions { + return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(th, unit.Sp(12), title) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(th, unit.Sp(13), body) + lbl.Color = th.Palette.Fg + return lbl.Layout(gtx) + }), + ) + }) +} + +func (u *ui) settingsChoiceRow(gtx layout.Context, choices ...choiceSpec) layout.Dimensions { + children := make([]layout.FlexChild, 0, len(choices)*2) + for i, choice := range choices { + if i > 0 { + children = append(children, layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout)) + } + current := choice + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return syncChoiceButton(gtx, u.theme, current.Click, current.Label, current.Active) + })) + } + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, children...) +} + +type listRowColors struct { + Title color.NRGBA + Meta color.NRGBA + Secondary color.NRGBA + Divider color.NRGBA + Fill color.NRGBA + Edge color.NRGBA +} + +func (u *ui) listRowColors(selected, focused, recycleBin bool) listRowColors { + colors := listRowColors{ + Title: accentColor, + Meta: color.NRGBA{R: 61, G: 60, B: 56, A: 255}, + Secondary: mutedColor, + Divider: color.NRGBA{R: 225, G: 219, B: 210, A: 255}, + Fill: color.NRGBA{R: 231, G: 239, B: 235, A: 255}, + Edge: color.NRGBA{R: 69, G: 118, B: 97, A: 255}, + } + if selected { + colors.Title = color.NRGBA{R: 19, G: 57, B: 43, A: 255} + colors.Meta = color.NRGBA{R: 31, G: 53, B: 44, A: 255} + colors.Secondary = color.NRGBA{R: 72, G: 88, B: 80, A: 255} + colors.Divider = color.NRGBA{R: 173, G: 196, B: 184, A: 255} + colors.Fill = color.NRGBA{R: 212, G: 228, B: 220, A: 255} + colors.Edge = color.NRGBA{R: 46, G: 106, B: 82, A: 255} + } + if recycleBin { + colors.Fill = color.NRGBA{R: 244, G: 229, B: 219, A: 255} + colors.Edge = color.NRGBA{R: 133, G: 65, B: 41, A: 255} + } + if focused && !selected { + colors.Meta = color.NRGBA{R: 49, G: 74, B: 63, A: 255} + colors.Secondary = color.NRGBA{R: 86, G: 102, B: 95, A: 255} + colors.Divider = color.NRGBA{R: 190, G: 208, B: 199, A: 255} + } + if u.accessibilityPrefs.Contrast == contrastHigh { + colors.Meta = color.NRGBA{R: 39, G: 39, B: 36, A: 255} + colors.Secondary = color.NRGBA{R: 58, G: 57, B: 52, A: 255} + if focused || selected { + colors.Fill = color.NRGBA{R: 211, G: 228, B: 219, A: 255} + colors.Edge = color.NRGBA{R: 16, G: 60, B: 44, A: 255} + } + } + if u.accessibilityPrefs.KeyboardFocus == keyboardFocusProminent && focused && !selected { + colors.Fill = color.NRGBA{R: 220, G: 234, B: 226, A: 255} + colors.Edge = color.NRGBA{R: 20, G: 74, B: 55, A: 255} + } + if recycleBin && (focused || selected) && u.accessibilityPrefs.Contrast == contrastHigh { + colors.Fill = color.NRGBA{R: 242, G: 223, B: 209, A: 255} + colors.Edge = color.NRGBA{R: 116, G: 43, B: 19, A: 255} + } + return colors +}