Add accessibility settings preferences

This commit is contained in:
Joe Julian
2026-04-01 17:43:17 -07:00
parent b813c8f221
commit 9a920f9a5a
5 changed files with 859 additions and 371 deletions
+245 -65
View File
@@ -153,8 +153,28 @@ type uiPreferences struct {
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
@@ -216,6 +236,9 @@ type ui struct {
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
@@ -240,6 +263,14 @@ type ui struct {
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
@@ -293,6 +324,9 @@ type ui struct {
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
@@ -378,6 +412,9 @@ type ui struct {
denseLayout bool
statusBannerTTL time.Duration
autofillNoticePreference autofillNoticeMode
autofillFirstFillApprovalMode autofillFirstFillApprovalMode
accessibilityPrefs accessibilityPreferences
settingsDraft settingsDraft
recentVaults []string
recentRemotes []recentRemoteRecord
recentVaultGroups map[string][]string
@@ -486,6 +523,9 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
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},
},
@@ -515,6 +555,8 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
lifecycleAdvancedHidden: true,
historyHidden: true,
statusBannerTTL: statusBannerDuration,
accessibilityPrefs: defaultAccessibilityPreferences(),
autofillFirstFillApprovalMode: autofillFirstFillApprovalAsk,
now: time.Now,
syncSourceMode: syncSourceLocal,
syncDirection: syncDirectionPull,
@@ -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)) {
+77 -3
View File
@@ -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()
+25 -2
View File
@@ -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
}
+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 {
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 = '•'
+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
}