Add accessibility settings preferences

This commit is contained in:
Joe Julian
2026-04-01 17:43:17 -07:00
parent ad22b03b53
commit 07c277f425
5 changed files with 859 additions and 371 deletions
+245 -65
View File
@@ -153,8 +153,28 @@ type uiPreferences struct {
DenseLayout bool `json:"denseLayout"` DenseLayout bool `json:"denseLayout"`
StatusBannerMillis int `json:"statusBannerMillis,omitempty"` StatusBannerMillis int `json:"statusBannerMillis,omitempty"`
AutofillNoticeMode string `json:"autofillNoticeMode,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 { type entriesSectionState struct {
Path []string Path []string
SearchQuery string SearchQuery string
@@ -216,6 +236,9 @@ type ui struct {
attachmentName widget.Editor attachmentName widget.Editor
attachmentPath widget.Editor attachmentPath widget.Editor
exportAttachmentPath widget.Editor exportAttachmentPath widget.Editor
autofillBrowserAllowlist widget.Editor
autofillAppAllowlist widget.Editor
autofillPackageRules widget.Editor
list widget.List list widget.List
groupList widget.List groupList widget.List
detailList widget.List detailList widget.List
@@ -240,6 +263,14 @@ type ui struct {
closeSecuritySettings widget.Clickable closeSecuritySettings widget.Clickable
runAdvancedSync widget.Clickable runAdvancedSync widget.Clickable
saveSecuritySettings 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 editEntry widget.Clickable
cancelEdit widget.Clickable cancelEdit widget.Clickable
pickVaultPath widget.Clickable pickVaultPath widget.Clickable
@@ -293,6 +324,9 @@ type ui struct {
showSyncRemote widget.Clickable showSyncRemote widget.Clickable
showSyncPull widget.Clickable showSyncPull widget.Clickable
showSyncPush widget.Clickable showSyncPush widget.Clickable
showAutofillApprovalAsk widget.Clickable
showAutofillApprovalAllow widget.Clickable
showAutofillApprovalBlock widget.Clickable
allowApproval widget.Clickable allowApproval widget.Clickable
denyApproval widget.Clickable denyApproval widget.Clickable
cancelApproval widget.Clickable cancelApproval widget.Clickable
@@ -378,6 +412,9 @@ type ui struct {
denseLayout bool denseLayout bool
statusBannerTTL time.Duration statusBannerTTL time.Duration
autofillNoticePreference autofillNoticeMode autofillNoticePreference autofillNoticeMode
autofillFirstFillApprovalMode autofillFirstFillApprovalMode
accessibilityPrefs accessibilityPreferences
settingsDraft settingsDraft
recentVaults []string recentVaults []string
recentRemotes []recentRemoteRecord recentRemotes []recentRemoteRecord
recentVaultGroups map[string][]string recentVaultGroups map[string][]string
@@ -486,6 +523,9 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
attachmentName: widget.Editor{SingleLine: true, Submit: false}, attachmentName: widget.Editor{SingleLine: true, Submit: false},
attachmentPath: widget.Editor{SingleLine: true, Submit: false}, attachmentPath: widget.Editor{SingleLine: true, Submit: false},
exportAttachmentPath: 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: widget.List{
List: layout.List{Axis: layout.Vertical}, List: layout.List{Axis: layout.Vertical},
}, },
@@ -515,6 +555,8 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
lifecycleAdvancedHidden: true, lifecycleAdvancedHidden: true,
historyHidden: true, historyHidden: true,
statusBannerTTL: statusBannerDuration, statusBannerTTL: statusBannerDuration,
accessibilityPrefs: defaultAccessibilityPreferences(),
autofillFirstFillApprovalMode: autofillFirstFillApprovalAsk,
now: time.Now, now: time.Now,
syncSourceMode: syncSourceLocal, syncSourceMode: syncSourceLocal,
syncDirection: syncDirectionPull, syncDirection: syncDirectionPull,
@@ -544,6 +586,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
u.restoreStartupLifecycleTarget() u.restoreStartupLifecycleTarget()
u.loadUIPreferences() u.loadUIPreferences()
u.loadSettingsFormFromPreferences() u.loadSettingsFormFromPreferences()
u.loadSettingsDraft()
u.filter() u.filter()
u.syncAutofillCache() u.syncAutofillCache()
return u return u
@@ -1033,20 +1076,6 @@ func (u *ui) loadSecuritySettingsFromSession() {
u.securityKDF.SetText(settings.KDF) 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() { func (u *ui) clearMasterPassword() {
u.masterPassword.SetText("") u.masterPassword.SetText("")
} }
@@ -1322,6 +1351,22 @@ func (u *ui) loadUIPreferences() {
u.denseLayout = prefs.DenseLayout u.denseLayout = prefs.DenseLayout
u.statusBannerTTL = normalizedStatusBannerTTL(prefs.StatusBannerMillis) u.statusBannerTTL = normalizedStatusBannerTTL(prefs.StatusBannerMillis)
u.autofillNoticePreference = normalizedAutofillNoticeMode(prefs.AutofillNoticeMode) 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() { func (u *ui) saveUIPreferences() {
@@ -1338,6 +1383,16 @@ func (u *ui) saveUIPreferences() {
DenseLayout: u.denseLayout, DenseLayout: u.denseLayout,
StatusBannerMillis: int(u.statusBannerTTL / time.Millisecond), StatusBannerMillis: int(u.statusBannerTTL / time.Millisecond),
AutofillNoticeMode: string(u.autofillNoticePreference), 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 { if err != nil {
return 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) { func (u *ui) setStatusBannerTTL(value time.Duration) {
u.statusBannerTTL = normalizedStatusBannerTTL(int(value / time.Millisecond)) u.statusBannerTTL = normalizedStatusBannerTTL(int(value / time.Millisecond))
u.saveUIPreferences() u.saveUIPreferences()
@@ -1761,8 +1867,7 @@ func (u *ui) armDeleteCurrentGroupAction() {
u.syncCurrentPath() u.syncCurrentPath()
u.deleteGroupPath = append([]string(nil), u.currentPath...) u.deleteGroupPath = append([]string(nil), u.currentPath...)
u.state.ErrorMessage = "" u.state.ErrorMessage = ""
u.state.StatusMessage = fmt.Sprintf("Confirm deleting empty group %q.", strings.Join(u.displayPath(), " / ")) u.showStatusMessage(fmt.Sprintf("Confirm deleting empty group %q.", strings.Join(u.displayPath(), " / ")))
u.statusExpiresAt = u.now().Add(u.statusBannerTTL)
} }
func (u *ui) runAction(label string, action func() error) { 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{} u.statusExpiresAt = time.Time{}
return return
} }
u.state.StatusMessage = label + " complete" u.showStatusMessage(label + " complete")
u.statusExpiresAt = u.now().Add(u.statusBannerTTL)
} }
func (u *ui) runBackgroundAction(label string, prepare func() (func() error, error)) { 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{} u.statusExpiresAt = time.Time{}
return return
} }
u.state.StatusMessage = result.label + " complete" u.showStatusMessage(result.label + " complete")
u.statusExpiresAt = u.now().Add(u.statusBannerTTL)
} }
func (u *ui) cancelLifecycleBusyState() { func (u *ui) cancelLifecycleBusyState() {
@@ -2528,6 +2631,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
for u.openSecuritySettings.Clicked(gtx) { for u.openSecuritySettings.Clicked(gtx) {
u.loadSecuritySettingsFromSession() u.loadSecuritySettingsFromSession()
u.loadSettingsFormFromPreferences() u.loadSettingsFormFromPreferences()
u.loadSettingsDraft()
u.securityDialogOpen = true u.securityDialogOpen = true
} }
for u.setStatusBannerShort.Clicked(gtx) { 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) u.runAction("advanced synchronize vault", u.advancedSyncAction)
} }
for u.saveSecuritySettings.Clicked(gtx) { for u.saveSecuritySettings.Clicked(gtx) {
u.runAction("save security settings", u.saveSecuritySettingsAction) u.runAction("save settings", u.saveSecuritySettingsAction)
} }
for u.unlockVault.Clicked(gtx) { for u.unlockVault.Clicked(gtx) {
u.startUnlockAction() u.startUnlockAction()
@@ -2624,6 +2728,15 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
for u.showSyncPush.Clicked(gtx) { for u.showSyncPush.Clicked(gtx) {
u.syncDirection = syncDirectionPush 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) { for u.allowApproval.Clicked(gtx) {
u.runAction("allow API request", func() error { u.runAction("allow API request", func() error {
outcome := apiapproval.OutcomeAllowOnce outcome := apiapproval.OutcomeAllowOnce
@@ -3049,9 +3162,55 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions {
lbl.Color = accentColor lbl.Color = accentColor
return lbl.Layout(gtx) 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(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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 lbl.Color = mutedColor
return lbl.Layout(gtx) 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") check := material.CheckBox(u.theme, &u.settingsDenseLayout, "Use dense entry layout")
return check.Layout(gtx) 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(layout.Spacer{Height: unit.Dp(14)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(16), "Vault Security") 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(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 { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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, return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { 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(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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" { if u.mode == "phone" {
gtx.Constraints.Min.X = gtx.Constraints.Max.X 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 := material.Editor(u.theme, &u.search, "Search vault")
editor.Color = u.theme.Palette.Fg editor.Color = u.theme.Palette.Fg
editor.HintColor = mutedColor 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() inset, titleSize, metaSize, urlSize, pathSize, dividerGap := u.entryRowMetrics()
selected := item.ID == u.state.SelectedEntryID selected := item.ID == u.state.SelectedEntryID
focused := u.isFocused(listFocusID(idx)) focused := u.isFocused(listFocusID(idx))
titleColor := accentColor rowColors := u.listRowColors(selected, focused, u.state.Section == appstate.SectionRecycleBin)
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}
}
row := func(gtx layout.Context) layout.Dimensions { row := func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(inset).Layout(gtx, 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 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, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, titleSize, item.Title) lbl := material.Label(u.theme, titleSize, item.Title)
lbl.Color = titleColor lbl.Color = rowColors.Title
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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{} return layout.Dimensions{}
} }
lbl := material.Label(u.theme, metaSize, item.Username) lbl := material.Label(u.theme, metaSize, item.Username)
lbl.Color = metaColor lbl.Color = rowColors.Meta
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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{} return layout.Dimensions{}
} }
lbl := material.Label(u.theme, urlSize, item.URL) lbl := material.Label(u.theme, urlSize, item.URL)
lbl.Color = secondaryColor lbl.Color = rowColors.Secondary
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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{} return layout.Dimensions{}
} }
lbl := material.Label(u.theme, pathSize, pathText) lbl := material.Label(u.theme, pathSize, pathText)
lbl.Color = secondaryColor lbl.Color = rowColors.Secondary
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: dividerGap}.Layout), 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 { if w < 1 {
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)} 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 { if size.Y == 0 {
size.Y = gtx.Constraints.Max.Y size.Y = gtx.Constraints.Max.Y
} }
fillColor := color.NRGBA{R: 212, G: 228, B: 220, A: 255} paint.FillShape(gtx.Ops, rowColors.Fill, clip.Rect{Max: size}.Op())
edgeColor := color.NRGBA{R: 46, G: 106, B: 82, A: 255} paint.FillShape(gtx.Ops, rowColors.Edge, clip.Rect{Max: image.Pt(5, size.Y)}.Op())
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())
return layout.Dimensions{Size: size} return layout.Dimensions{Size: size}
}), }),
layout.Stacked(func(gtx layout.Context) layout.Dimensions { layout.Stacked(func(gtx layout.Context) layout.Dimensions {
@@ -4715,7 +4891,7 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions {
u.filter() u.filter()
} }
btn := material.Button(u.theme, &u.breadcrumbs[index], label) 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) btn.TextSize = unit.Sp(11)
if u.mode == "phone" { if u.mode == "phone" {
btn.TextSize = unit.Sp(9) 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 { func outlinedFieldStateWithPrefs(gtx layout.Context, prefs accessibilityPreferences, focused bool, w layout.Widget) layout.Dimensions {
appearance := fieldFocusAppearance(gtx.Metric, focused) appearance := fieldFocusAppearance(gtx.Metric, prefs, focused)
size := gtx.Constraints.Min size := gtx.Constraints.Min
if size.X == 0 { if size.X == 0 {
size.X = gtx.Constraints.Max.X 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 { func tonedButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string) layout.Dimensions {
btn := material.Button(th, click, label) 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.CornerRadius = unit.Dp(10)
btn.TextSize = unit.Sp(15) btn.TextSize = unit.Sp(15)
if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(460)) { if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(460)) {
+77 -3
View File
@@ -2671,9 +2671,9 @@ func TestUIAccessibilityLabelsDescribeFocusableControls(t *testing.T) {
func TestFieldFocusAppearanceScalesForHighDPI(t *testing.T) { func TestFieldFocusAppearanceScalesForHighDPI(t *testing.T) {
t.Parallel() t.Parallel()
lo := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, true) lo := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, defaultAccessibilityPreferences(), true)
hi := fieldFocusAppearance(unit.Metric{PxPerDp: 2.5, PxPerSp: 2.5}, true) hi := fieldFocusAppearance(unit.Metric{PxPerDp: 2.5, PxPerSp: 2.5}, defaultAccessibilityPreferences(), true)
unfocused := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, false) unfocused := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, defaultAccessibilityPreferences(), false)
if got := lo.MinHeight; got != 44 { if got := lo.MinHeight; got != 44 {
t.Fatalf("fieldFocusAppearance(low).MinHeight = %d, want 44", got) 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) { func TestUIAutofillStatusSurfaceUsesPendingApproval(t *testing.T) {
t.Parallel() 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) { func TestUIEntryRowMetricsUseDenseLayout(t *testing.T) {
t.Parallel() t.Parallel()
+25 -2
View File
@@ -19,26 +19,49 @@ type focusAppearance struct {
MinHeight int 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{ appearance := focusAppearance{
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255}, BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
OutlineColor: color.NRGBA{A: 0}, OutlineColor: color.NRGBA{A: 0},
OutlineWidth: max(1, metric.Dp(unit.Dp(1))), OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
MinHeight: metric.Dp(unit.Dp(44)), 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 { if focused {
appearance.BorderColor = accentColor appearance.BorderColor = accentColor
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72} appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2))) 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 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} background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
text = accentColor 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 { if focused {
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255} 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 return background, text
} }
+18 -16
View File
@@ -969,15 +969,15 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(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 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, 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(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(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(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(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 { 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 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, 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(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(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), u.passwordProfileOptionsText()) 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(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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 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), 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(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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 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), 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 { 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 { 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 func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, 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(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(11), help) 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) return lbl.Layout(gtx)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx, return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { 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( func labeledEditorWithFocus(
th *material.Theme, th *material.Theme,
prefs accessibilityPreferences,
label string, label string,
editor *widget.Editor, editor *widget.Editor,
sensitive bool, sensitive bool,
@@ -1326,7 +1327,7 @@ func labeledEditorWithFocus(
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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 mask := editor.Mask
if sensitive { if sensitive {
editor.Mask = '•' editor.Mask = '•'
@@ -1343,6 +1344,7 @@ func labeledEditorWithFocus(
func labeledMultilineEditorWithFocus( func labeledMultilineEditorWithFocus(
th *material.Theme, th *material.Theme,
prefs accessibilityPreferences,
label string, label string,
editor *widget.Editor, editor *widget.Editor,
sensitive bool, sensitive bool,
@@ -1357,7 +1359,7 @@ func labeledMultilineEditorWithFocus(
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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 mask := editor.Mask
if sensitive { if sensitive {
editor.Mask = '•' editor.Mask = '•'
+209
View File
@@ -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
}