From 2f13b0d42fc202bc8a632840520915a50f6f5032 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Wed, 1 Apr 2026 17:38:02 -0700 Subject: [PATCH] Add persisted UI settings preferences --- main.go | 662 ++++++++++++++++++++++++++------------------------- main_test.go | 82 +++++++ 2 files changed, 426 insertions(+), 318 deletions(-) diff --git a/main.go b/main.go index 82739d4..40dcc8f 100644 --- a/main.go +++ b/main.go @@ -141,6 +141,7 @@ type uiPreferences struct { GroupControlsHidden bool `json:"groupControlsHidden"` LifecycleAdvancedHidden bool `json:"lifecycleAdvancedHidden"` HistoryHidden bool `json:"historyHidden"` + DenseLayout bool `json:"denseLayout"` } type entriesSectionState struct { @@ -165,217 +166,219 @@ 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 - openRemotePrefsHelp widget.Clickable - closeAdvancedSync widget.Clickable - closeSecuritySettings widget.Clickable - closeRemotePrefsHelp 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 - 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 - 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 - remotePrefsDialogOpen bool - showSyncPassword bool - keyboardFocus focusID - defaultSaveAsPath string - recentVaultsPath string - uiPreferencesPath string - recentRemotesPath string - autofillCachePath string - editingEntry bool - groupControlsHidden bool - lifecycleAdvancedHidden bool - historyHidden bool - 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 + 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 + 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 + 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 { @@ -519,6 +522,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) u.loadRecentRemotes() u.restoreStartupLifecycleTarget() u.loadUIPreferences() + u.loadSettingsFormFromPreferences() u.filter() u.syncAutofillCache() return u @@ -1016,6 +1020,8 @@ func (u *ui) saveSecuritySettingsAction() error { if err := u.state.ConfigureSecurity(settings); err != nil { return err } + u.applySettingsFormToPreferences() + u.saveUIPreferences() u.securityDialogOpen = false return nil } @@ -1292,6 +1298,7 @@ func (u *ui) loadUIPreferences() { u.groupControlsHidden = prefs.GroupControlsHidden u.lifecycleAdvancedHidden = prefs.LifecycleAdvancedHidden u.historyHidden = prefs.HistoryHidden + u.denseLayout = prefs.DenseLayout } func (u *ui) saveUIPreferences() { @@ -1305,6 +1312,7 @@ func (u *ui) saveUIPreferences() { GroupControlsHidden: u.groupControlsHidden, LifecycleAdvancedHidden: u.lifecycleAdvancedHidden, HistoryHidden: u.historyHidden, + DenseLayout: u.denseLayout, }, "", " ") if err != nil { return @@ -1312,6 +1320,20 @@ func (u *ui) saveUIPreferences() { _ = os.WriteFile(u.uiPreferencesPath, content, 0o600) } +func (u *ui) loadSettingsFormFromPreferences() { + u.settingsGroupControls.Value = u.groupControlsHidden + u.settingsLifecycleAdvanced.Value = u.lifecycleAdvancedHidden + u.settingsHistory.Value = u.historyHidden + u.settingsDenseLayout.Value = u.denseLayout +} + +func (u *ui) applySettingsFormToPreferences() { + u.groupControlsHidden = u.settingsGroupControls.Value + u.lifecycleAdvancedHidden = u.settingsLifecycleAdvanced.Value + u.historyHidden = u.settingsHistory.Value + u.denseLayout = u.settingsDenseLayout.Value +} + func (u *ui) noteRecentRemote(baseURL, path, username, password string, rememberAuth bool) { baseURL = strings.TrimSpace(baseURL) path = strings.TrimSpace(path) @@ -1428,28 +1450,20 @@ func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) { u.rememberRemoteAuth.Value = strings.TrimSpace(record.Username) != "" || record.Password != "" } -func (u *ui) remotePreferencesCurrentSummary() string { +func (u *ui) remoteAuthStatusMessage() string { selected, hasSelected := u.selectedRecentRemoteRecord() switch { case !u.rememberRemoteAuth.Value: - return "Current choice: KeePassGO will remember only the WebDAV location for this connection." + return "Only the location will be saved in Recent Connections." case hasSelected && (strings.TrimSpace(selected.Username) != "" || selected.Password != ""): - return "Current choice: a successful open will update the saved sign-in for this connection on this device." + return "Saved sign-in will be updated for this connection." case strings.TrimSpace(u.remoteUsername.Text()) != "" || u.remotePassword.Text() != "": - return "Current choice: a successful open will save the entered sign-in for this connection on this device." + return "This sign-in will be saved in Recent Connections after a successful open." default: - return "Current choice: sign-in retention is enabled, but no username or password is entered yet." + return "Enter a username or password to save sign-in details for this connection." } } -func (u *ui) remotePreferencesAlwaysSavedSummary() string { - return "Recent Connections always stores the WebDAV base URL, remote path, and the last group you opened for that connection." -} - -func (u *ui) remotePreferencesRetentionSummary() string { - return "KeePassGO keeps up to six recent connections. Turning off Remember sign-in and reopening rewrites that connection without the saved username or password." -} - func (u *ui) noteCurrentRemotePath() { status, ok := u.state.Session.(sessionStatus) if !ok || !status.IsRemote() || status.IsLocked() { @@ -2450,11 +2464,9 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { } for u.openSecuritySettings.Clicked(gtx) { u.loadSecuritySettingsFromSession() + u.loadSettingsFormFromPreferences() u.securityDialogOpen = true } - for u.openRemotePrefsHelp.Clicked(gtx) { - u.remotePrefsDialogOpen = true - } for u.closeAdvancedSync.Clicked(gtx) { u.syncDialogOpen = false u.showSyncPassword = false @@ -2462,9 +2474,6 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.closeSecuritySettings.Clicked(gtx) { u.securityDialogOpen = false } - for u.closeRemotePrefsHelp.Clicked(gtx) { - u.remotePrefsDialogOpen = false - } for u.runAdvancedSync.Clicked(gtx) { u.runAction("advanced synchronize vault", u.advancedSyncAction) } @@ -2870,12 +2879,6 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { } return u.securityDialog(gtx) }), - layout.Stacked(func(gtx layout.Context) layout.Dimensions { - if !u.remotePrefsDialogOpen { - return layout.Dimensions{} - } - return u.remotePrefsDialog(gtx) - }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { if _, ok := u.pendingApproval(); !ok { return layout.Dimensions{} @@ -2958,43 +2961,55 @@ func (u *ui) securityDialog(gtx layout.Context) layout.Dimensions { ) } -func (u *ui) remotePrefsDialog(gtx layout.Context) layout.Dimensions { - return layout.Stack{}.Layout(gtx, - layout.Expanded(func(gtx layout.Context) layout.Dimensions { - paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op()) - return layout.Dimensions{Size: gtx.Constraints.Max} - }), - layout.Stacked(func(gtx layout.Context) layout.Dimensions { - return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - width := gtx.Dp(unit.Dp(660)) - if width > gtx.Constraints.Max.X { - width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24)) - } - if width < 1 { - width = gtx.Constraints.Max.X - } - gtx.Constraints.Min.X = width - gtx.Constraints.Max.X = width - return card(gtx, u.remotePrefsDialogContent) - }) - }), - ) -} - func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(20), "Security Settings") + lbl := material.Label(u.theme, unit.Sp(20), "Settings") lbl.Color = accentColor return lbl.Layout(gtx) }), 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 the KDBX cipher and KDF family KeePassGO should use for new or future saves.") + lbl := material.Label(u.theme, unit.Sp(14), "Choose how KeePassGO remembers UI layout behavior and which KDBX security settings it should use for new or future saves.") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(16), "UI Preferences") + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + check := material.CheckBox(u.theme, &u.settingsGroupControls, "Keep Group Tools collapsed") + return check.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + check := material.CheckBox(u.theme, &u.settingsLifecycleAdvanced, "Keep advanced lifecycle controls collapsed") + return check.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + check := material.CheckBox(u.theme, &u.settingsHistory, "Keep entry history collapsed") + return check.Layout(gtx) + }), + layout.Rigid(func(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") + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(labeledEditorHelp(u.theme, "Cipher", "Supported values: "+strings.Join([]string{vault.CipherAES256, vault.CipherChaCha20}, ", "), &u.securityCipher, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(labeledEditorHelp(u.theme, "KDF", "Supported values: "+strings.Join([]string{vault.KDFAES, vault.KDFArgon2}, ", "), &u.securityKDF, false)), @@ -3006,49 +3021,13 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.saveSecuritySettings, "Save Security Settings") + return tonedButton(gtx, u.theme, &u.saveSecuritySettings, "Save Settings") }), ) }), ) } -func (u *ui) remotePrefsDialogContent(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(20), "Remote Connection Settings & Help") - lbl.Color = accentColor - return lbl.Layout(gtx) - }), - 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), "Use Recent Connections to reopen WebDAV-backed vaults quickly without cluttering the main open flow.") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return approvalFact(u.theme, "Current", u.remotePreferencesCurrentSummary(), "")(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return approvalFact(u.theme, "Always Saved", u.remotePreferencesAlwaysSavedSummary(), "")(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return approvalFact(u.theme, "Retention", u.remotePreferencesRetentionSummary(), "")(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return approvalFact(u.theme, "When Sign-in Saves", "Username and password or app token are only stored after a successful remote open when Remember sign-in is enabled.", "")(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.closeRemotePrefsHelp, "Done") - }), - ) -} - func (u *ui) approvalDialog(gtx layout.Context) layout.Dimensions { return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { @@ -3355,7 +3334,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { layout.Rigid(u.syncButtonGroup), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.IconButton(u.theme, &u.openSecuritySettings, u.settingsIcon, "Vault settings") + btn := material.IconButton(u.theme, &u.openSecuritySettings, u.settingsIcon, "Settings") btn.Background = selectedColor btn.Color = accentColor btn.Size = unit.Dp(18) @@ -3422,12 +3401,58 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { }) } +func (u *ui) sectionSpacing() unit.Dp { + if u.mode == "phone" { + if u.denseLayout { + return unit.Dp(4) + } + return unit.Dp(6) + } + if u.denseLayout { + return unit.Dp(8) + } + return unit.Dp(12) +} + +func (u *ui) entryRowMetrics() (unit.Dp, unit.Sp, unit.Sp, unit.Sp, unit.Sp, unit.Dp) { + inset := unit.Dp(12) + titleSize := unit.Sp(17) + metaSize := unit.Sp(14) + urlSize := unit.Sp(12) + pathSize := unit.Sp(11) + dividerGap := unit.Dp(7) + if u.denseLayout { + inset = unit.Dp(9) + titleSize = unit.Sp(15) + metaSize = unit.Sp(12) + urlSize = unit.Sp(11) + pathSize = unit.Sp(10) + dividerGap = unit.Dp(5) + } + if u.mode == "phone" { + inset = unit.Dp(9) + titleSize = unit.Sp(15) + metaSize = unit.Sp(12) + urlSize = unit.Sp(11) + pathSize = unit.Sp(10) + dividerGap = unit.Dp(5) + if u.denseLayout { + inset = unit.Dp(8) + titleSize = unit.Sp(14) + metaSize = unit.Sp(11) + urlSize = unit.Sp(10) + pathSize = unit.Sp(9) + dividerGap = unit.Dp(4) + } + } + return inset, titleSize, metaSize, urlSize, pathSize, dividerGap +} + func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { panel := card - spacing := unit.Dp(12) + spacing := u.sectionSpacing() if u.mode == "phone" { panel = compactCard - spacing = unit.Dp(6) } u.ensureNavClickables() return panel(gtx, func(gtx layout.Context) layout.Dimensions { @@ -3614,18 +3639,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item u.loadSelectedEntryIntoEditor() } return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - inset := unit.Dp(12) - titleSize := unit.Sp(17) - metaSize := unit.Sp(14) - urlSize := unit.Sp(12) - pathSize := unit.Sp(11) - if u.mode == "phone" { - inset = unit.Dp(9) - titleSize = unit.Sp(15) - metaSize = unit.Sp(12) - urlSize = unit.Sp(11) - pathSize = unit.Sp(10) - } + inset, titleSize, metaSize, urlSize, pathSize, dividerGap := u.entryRowMetrics() selected := item.ID == u.state.SelectedEntryID focused := u.isFocused(listFocusID(idx)) titleColor := accentColor @@ -3696,7 +3710,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item lbl.Color = secondaryColor return lbl.Layout(gtx) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(7)}.Layout), + layout.Rigid(layout.Spacer{Height: dividerGap}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { w := gtx.Constraints.Max.X if w < 1 { @@ -3808,7 +3822,7 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions { layout.Rigid(u.syncButtonGroup), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.IconButton(u.theme, &u.openSecuritySettings, u.settingsIcon, "Vault settings") + btn := material.IconButton(u.theme, &u.openSecuritySettings, u.settingsIcon, "Settings") btn.Background = selectedColor btn.Color = accentColor btn.Size = unit.Dp(18) @@ -3947,10 +3961,22 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions { titleSize := unit.Sp(26) titlePad := unit.Dp(10) sectionGap := unit.Dp(6) + cardGap := unit.Dp(8) + if u.denseLayout { + titlePad = unit.Dp(6) + sectionGap = unit.Dp(4) + cardGap = unit.Dp(6) + } if u.mode == "phone" { titleSize = unit.Sp(18) titlePad = unit.Dp(4) sectionGap = unit.Dp(4) + cardGap = unit.Dp(6) + if u.denseLayout { + titlePad = unit.Dp(3) + sectionGap = unit.Dp(3) + cardGap = unit.Dp(4) + } } rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { @@ -4068,11 +4094,11 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions { ) }) }, - layout.Spacer{Height: unit.Dp(8)}.Layout, + layout.Spacer{Height: cardGap}.Layout, u.attachmentSummaryPanel, - layout.Spacer{Height: unit.Dp(8)}.Layout, + layout.Spacer{Height: cardGap}.Layout, u.historyPanel, - layout.Spacer{Height: unit.Dp(8)}.Layout, + layout.Spacer{Height: cardGap}.Layout, func(gtx layout.Context) layout.Dimensions { switch u.state.Section { case appstate.SectionTemplates: diff --git a/main_test.go b/main_test.go index b1962fb..c2f8474 100644 --- a/main_test.go +++ b/main_test.go @@ -865,6 +865,48 @@ func TestUISaveSecuritySettingsUpdatesExistingVault(t *testing.T) { } } +func TestUISaveSettingsPersistsUIPreferences(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + configPath := filepath.Join(dir, "ui-prefs.json") + + u := newUIWithSession("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), + RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), + RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), + UIPreferencesPath: configPath, + }) + u.settingsGroupControls.Value = true + u.settingsLifecycleAdvanced.Value = false + u.settingsHistory.Value = false + u.settingsDenseLayout.Value = true + + if err := u.saveSecuritySettingsAction(); err != nil { + t.Fatalf("saveSecuritySettingsAction() error = %v", err) + } + + reloaded := newUIWithSession("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), + RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), + RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), + UIPreferencesPath: configPath, + }) + + if !reloaded.groupControlsHidden { + t.Fatal("groupControlsHidden after reload = false, want true") + } + if reloaded.lifecycleAdvancedHidden { + t.Fatal("lifecycleAdvancedHidden after reload = true, want false") + } + if reloaded.historyHidden { + t.Fatal("historyHidden after reload = true, want false") + } + if !reloaded.denseLayout { + t.Fatal("denseLayout after reload = false, want true") + } +} + func TestUILockAndUnlockClearMasterPasswordField(t *testing.T) { t.Parallel() @@ -3420,6 +3462,46 @@ func TestUIGroupToolsDisclosureStatePersists(t *testing.T) { } } +func TestUIDenseLayoutPreferencePersists(t *testing.T) { + t.Parallel() + + configPath := filepath.Join(t.TempDir(), "ui-prefs.json") + + first := newUIWithSession("desktop", &session.Manager{}) + first.uiPreferencesPath = configPath + first.denseLayout = true + first.saveUIPreferences() + + second := newUIWithSession("desktop", &session.Manager{}) + second.uiPreferencesPath = configPath + second.denseLayout = false + second.loadUIPreferences() + + if !second.denseLayout { + t.Fatal("denseLayout = false after reload, want true") + } +} + +func TestUIEntryRowMetricsUseDenseLayout(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + comfortableInset, comfortableTitle, _, _, _, comfortableGap := u.entryRowMetrics() + u.denseLayout = true + denseInset, denseTitle, _, _, _, denseGap := u.entryRowMetrics() + + if denseInset >= comfortableInset { + t.Fatalf("dense inset = %v, want smaller than comfortable inset %v", denseInset, comfortableInset) + } + if denseTitle >= comfortableTitle { + t.Fatalf("dense title size = %v, want smaller than comfortable title size %v", denseTitle, comfortableTitle) + } + if denseGap >= comfortableGap { + t.Fatalf("dense divider gap = %v, want smaller than comfortable divider gap %v", denseGap, comfortableGap) + } +} + func TestSelectingRecentRemoteConnectionKeepsPasswordMasked(t *testing.T) { t.Parallel()