449 lines
14 KiB
Go
449 lines
14 KiB
Go
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.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
|
|
}
|