package appui import ( "encoding/json" "fmt" "image" "image/color" "os" "path/filepath" "strings" "time" "gioui.org/layout" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" editormodel "git.julianfamily.org/keepassgo/internal/appui/editor" headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout" "git.julianfamily.org/keepassgo/internal/appui/platform" settingsmodel "git.julianfamily.org/keepassgo/internal/appui/settings" "git.julianfamily.org/keepassgo/internal/vault" ) const ( displayDensityDense = settingsmodel.DisplayDensityDense displayDensityComfortable = settingsmodel.DisplayDensityComfortable contrastStandard = settingsmodel.ContrastStandard contrastHigh = settingsmodel.ContrastHigh keyboardFocusStandard = settingsmodel.KeyboardFocusStandard keyboardFocusProminent = settingsmodel.KeyboardFocusProminent ) type accessibilityPreferences = settingsmodel.AccessibilityPreferences type settingsFile struct { Sync syncSettings `json:"sync,omitempty"` Debug debugSettings `json:"debug,omitempty"` } type syncSettings struct { SourceDefault string `json:"sourceDefault,omitempty"` DirectionDefault string `json:"directionDefault,omitempty"` AutoSaveRemote bool `json:"autoSaveRemote,omitempty"` } type debugSettings struct { LogHeaderBounds bool `json:"logHeaderBounds,omitempty"` } type syncSettingsDraft struct { SourceDefault syncSourceMode DirectionDefault syncDirection AutoSaveRemote bool } type settingsDraft struct { Accessibility accessibilityPreferences Sync syncSettingsDraft Debug debugSettings } type legacySyncPreferences struct { SyncSourceDefault string `json:"syncSourceDefault,omitempty"` SyncDirectionDefault string `json:"syncDirectionDefault,omitempty"` } type choiceSpec struct { Click *widget.Clickable Label string Active bool } type focusAppearance struct { BorderColor color.NRGBA OutlineColor color.NRGBA OutlineWidth int MinHeight int } func defaultAccessibilityPreferences() accessibilityPreferences { return settingsmodel.DefaultAccessibilityPreferences() } func displayDensityForDenseLayout(dense bool) string { return settingsmodel.DisplayDensityForDenseLayout(dense) } func normalizeAccessibilityPreferences(prefs accessibilityPreferences) accessibilityPreferences { return settingsmodel.NormalizeAccessibilityPreferences(prefs) } func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) { normalized := normalizeAccessibilityPreferences(prefs) u.denseLayout = normalized.DisplayDensity == displayDensityDense u.accessibilityPrefs = normalized } func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance { prefs = normalizeAccessibilityPreferences(prefs) appearance := focusAppearance{ BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255}, OutlineColor: color.NRGBA{A: 0}, OutlineWidth: max(1, metric.Dp(unit.Dp(1))), MinHeight: metric.Dp(unit.Dp(44)), } if prefs.DisplayDensity == displayDensityComfortable { appearance.MinHeight = metric.Dp(unit.Dp(52)) } if prefs.Contrast == contrastHigh { appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255} } if focused { appearance.BorderColor = accentColor appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72} appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2))) if prefs.Contrast == contrastHigh { appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255} appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124} } if prefs.KeyboardFocus == keyboardFocusProminent { appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3))) appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148} } } return appearance } func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) { prefs = normalizeAccessibilityPreferences(prefs) background = color.NRGBA{R: 231, G: 239, B: 235, A: 255} text = accentColor if prefs.Contrast == contrastHigh { background = color.NRGBA{R: 225, G: 235, B: 230, A: 255} text = color.NRGBA{R: 19, G: 57, B: 43, A: 255} } if focused { background = color.NRGBA{R: 214, G: 229, B: 221, A: 255} if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent { background = color.NRGBA{R: 202, G: 222, B: 212, A: 255} } } return background, text } func (u *ui) accessibilityLabel(id focusID) string { switch { case id == focusSearch: return "Search vault" case strings.HasPrefix(string(id), "breadcrumb:"): index := focusIndex(id) crumbs := u.breadcrumbLabels() if index >= 0 && index < len(crumbs) { return fmt.Sprintf("Navigate to %s", crumbs[index]) } case strings.HasPrefix(string(id), "list:"): index := focusIndex(id) if index >= 0 && index < len(u.visible) { return fmt.Sprintf("Select entry %s", u.visible[index].Title) } case strings.HasPrefix(string(id), "detail:"): name := strings.TrimPrefix(string(id), "detail:") return fmt.Sprintf("Edit %s", detailFieldLabel(detailField(name))) } return "" } func drawFocusOutline(gtx layout.Context, appearance focusAppearance, size image.Point) layout.Dimensions { if appearance.OutlineColor.A == 0 || appearance.OutlineWidth <= 0 { return layout.Dimensions{Size: size} } width := appearance.OutlineWidth paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(size.X, width)}.Op()) paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(0, size.Y-width), Max: image.Pt(size.X, size.Y)}.Op()) paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(width, size.Y)}.Op()) paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(size.X-width, 0), Max: image.Pt(size.X, size.Y)}.Op()) return layout.Dimensions{Size: size} } func (u *ui) isFocused(id focusID) bool { return u.keyboardFocus == id } func detailFieldLabel(field detailField) string { return editormodel.Label(field) } func (u *ui) loadSettingsDraft() { u.settingsDraft = settingsDraft{ Accessibility: accessibilityPreferences{ DisplayDensity: displayDensityForDenseLayout(u.denseLayout), Contrast: u.accessibilityPrefs.Contrast, ReducedMotion: u.accessibilityPrefs.ReducedMotion, KeyboardFocus: u.accessibilityPrefs.KeyboardFocus, }, Sync: syncSettingsDraft{ SourceDefault: u.syncDefaultSourceMode, DirectionDefault: u.syncDefaultDirection, AutoSaveRemote: u.autoSaveRemote, }, Debug: debugSettings{ LogHeaderBounds: u.debugLogHeaderBounds, }, } u.settingsDebugHeaderBounds.Value = u.settingsDraft.Debug.LogHeaderBounds u.settingsAutoSaveRemote.Value = u.settingsDraft.Sync.AutoSaveRemote } func (u *ui) saveSecuritySettingsAction() error { if err := u.applySecuritySettingsLive(); err != nil { return err } u.securityDialogOpen = false return nil } func (u *ui) applySecuritySettingsLive() 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.settingsDraft.Debug.LogHeaderBounds = u.settingsDebugHeaderBounds.Value u.settingsDraft.Sync.AutoSaveRemote = u.settingsAutoSaveRemote.Value u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault) u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault) u.autoSaveRemote = u.settingsDraft.Sync.AutoSaveRemote u.state.AutoSaveRemote = u.autoSaveRemote u.debugLogHeaderBounds = u.settingsDraft.Debug.LogHeaderBounds if !u.debugLogHeaderBounds { u.lastHeaderBoundsLog = "" } u.applySettingsFormToPreferences() u.applyAccessibilityPreferences(u.settingsDraft.Accessibility) u.saveSettings() u.saveUIPreferences() return nil } func (u *ui) loadSettings() { u.syncDefaultSourceMode = syncSourceLocal u.syncDefaultDirection = syncDirectionPull u.autoSaveRemote = false if strings.TrimSpace(u.settingsPath) != "" { content, err := os.ReadFile(u.settingsPath) if err == nil { var settings settingsFile if json.Unmarshal(content, &settings) == nil { u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault)) u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault)) u.autoSaveRemote = settings.Sync.AutoSaveRemote u.state.AutoSaveRemote = u.autoSaveRemote u.debugLogHeaderBounds = settings.Debug.LogHeaderBounds return } } } u.loadLegacySyncDefaultsFromUIPreferences() u.state.AutoSaveRemote = u.autoSaveRemote } func (u *ui) loadLegacySyncDefaultsFromUIPreferences() { if strings.TrimSpace(u.uiPreferencesPath) == "" { return } content, err := os.ReadFile(u.uiPreferencesPath) if err != nil { return } var prefs legacySyncPreferences if err := json.Unmarshal(content, &prefs); err != nil { return } u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(prefs.SyncSourceDefault)) u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(prefs.SyncDirectionDefault)) } func (u *ui) saveSettings() { if strings.TrimSpace(u.settingsPath) == "" { return } if err := os.MkdirAll(filepath.Dir(u.settingsPath), 0o700); err != nil { return } content, err := json.MarshalIndent(settingsFile{ Sync: syncSettings{ SourceDefault: string(u.syncDefaultSourceMode), DirectionDefault: string(u.syncDefaultDirection), AutoSaveRemote: u.autoSaveRemote, }, Debug: debugSettings{ LogHeaderBounds: u.debugLogHeaderBounds, }, }, "", " ") if err != nil { return } _ = os.WriteFile(u.settingsPath, content, 0o600) } func (u *ui) maybeLogHeaderBounds(bounds headerButtonBounds) { if !u.debugLogHeaderBounds { return } line := bounds.logLine(u.mode) if line == u.lastHeaderBoundsLog { return } platform.LogInfo("KeePassGO", line) u.lastHeaderBoundsLog = line } func (u *ui) maybeLogHeaderMenuToggle(menu string, open bool) { if !u.debugLogHeaderBounds { return } platform.LogInfo("KeePassGO", fmt.Sprintf("keepassgo header-menu-toggle menu=%s open=%t", menu, open)) } func (u *ui) maybeLogHeaderMenuPlacement(menu string, surface headerlayout.DropdownSurface, placement headerlayout.DropdownPlacement) { if !u.debugLogHeaderBounds { return } platform.LogInfo("KeePassGO", fmt.Sprintf( "keepassgo header-menu-placement menu=%s anchor=%d,%d origin=%d,%d size=%dx%d container=%d inset=%d,%d", menu, placement.Anchor.TriggerRightX, placement.Anchor.TriggerBottomY, placement.Origin.X, placement.Origin.Y, placement.Size.X, placement.Size.Y, surface.ContainerWidth, surface.LeftInset, surface.TopInset, )) } 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.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 }