diff --git a/internal/appui/app.go b/internal/appui/app.go index d1217ad..04c73ed 100644 --- a/internal/appui/app.go +++ b/internal/appui/app.go @@ -377,6 +377,7 @@ type ui struct { settingsLifecycleAdvanced widget.Bool settingsHistory widget.Bool settingsDenseLayout widget.Bool + settingsDebugHeaderBounds widget.Bool entryClicks []widget.Clickable apiTokenClicks []widget.Clickable apiPolicyRemoves []widget.Clickable @@ -477,6 +478,7 @@ type ui struct { autofillNoticePreference autofillNoticeMode autofillFirstFillApprovalMode autofillFirstFillApprovalMode accessibilityPrefs accessibilityPreferences + debugLogHeaderBounds bool settingsDraft settingsDraft recentVaults []string recentRemotes []recentRemoteRecord @@ -502,6 +504,8 @@ type ui struct { lastLifecycleAction string pendingLifecycleOpenIntent lifecycleOpenIntent requestMasterPassFocus bool + lastHeaderBoundsLog string + frameInsetPx int invalidate func() } @@ -1136,6 +1140,16 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions { check := material.CheckBox(u.theme, &u.settingsHistory, "Keep entry history collapsed") return check.Layout(gtx) }, + func(gtx layout.Context) layout.Dimensions { + check := material.CheckBox(u.theme, &u.settingsDebugHeaderBounds, "Log compact header button bounds") + return check.Layout(gtx) + }, + layout.Spacer{Height: unit.Dp(4)}.Layout, + func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "Write compact Android header button screen coordinates to the app log so emulator taps can read exact bounds from logcat.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }, layout.Spacer{Height: unit.Dp(14)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(16), "Vault Security") diff --git a/internal/appui/frame.go b/internal/appui/frame.go index 85b5396..7662dc7 100644 --- a/internal/appui/frame.go +++ b/internal/appui/frame.go @@ -603,6 +603,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.handleGroupClicks(gtx) u.handleInputUpdates(gtx) u.updateViewportLayoutMode(gtx) + u.frameInsetPx = gtx.Dp(unit.Dp(16)) inset := layout.UniformInset(unit.Dp(16)) return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { diff --git a/internal/appui/header.go b/internal/appui/header.go index 3b81a32..d73df2c 100644 --- a/internal/appui/header.go +++ b/internal/appui/header.go @@ -1,6 +1,7 @@ package appui import ( + "fmt" "image" "gioui.org/layout" @@ -39,11 +40,14 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} } spacing := gtx.Dp(unit.Dp(8)) - metrics := headerlayout.ActionMetrics{Spacing: spacing} + metrics := headerlayout.ActionMetrics{Spacing: spacing, SyncInnerSpacing: gtx.Dp(unit.Dp(3))} + if !u.usesCompactViewport() { + metrics.SyncInnerSpacing = gtx.Dp(unit.Dp(4)) + } actionCluster := func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - metrics.SyncDims = u.syncButtonGroup(gtx) + metrics.SyncDims, metrics.SyncPrimaryDims, metrics.SyncToggleDims = u.syncButtonGroupWithMetrics(gtx) return metrics.SyncDims }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), @@ -71,6 +75,9 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { rowCall.Add(gtx.Ops) return metrics.RowDims }) + if u.usesCompactViewport() { + u.maybeLogHeaderBounds(newHeaderButtonBounds(image.Pt(u.frameInsetPx, u.frameInsetPx), metrics.Bounds())) + } if u.usesCompactViewport() { if u.syncMenuOpen { @@ -94,6 +101,37 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { return rowDims } +type headerButtonBounds struct { + SyncPrimary image.Rectangle + SyncToggle image.Rectangle + Lock image.Rectangle + MainMenu image.Rectangle +} + +func newHeaderButtonBounds(origin image.Point, bounds headerlayout.ActionBounds) headerButtonBounds { + return headerButtonBounds{ + SyncPrimary: bounds.SyncPrimary.Add(origin), + SyncToggle: bounds.SyncToggle.Add(origin), + Lock: bounds.Lock.Add(origin), + MainMenu: bounds.MainMenu.Add(origin), + } +} + +func (b headerButtonBounds) logLine(mode string) string { + return fmt.Sprintf( + "keepassgo header-bounds mode=%s sync=%s sync_toggle=%s lock=%s menu=%s", + mode, + formatHeaderRect(b.SyncPrimary), + formatHeaderRect(b.SyncToggle), + formatHeaderRect(b.Lock), + formatHeaderRect(b.MainMenu), + ) +} + +func formatHeaderRect(rect image.Rectangle) string { + return fmt.Sprintf("%d,%d-%d,%d", rect.Min.X, rect.Min.Y, rect.Max.X, rect.Max.Y) +} + func (u *ui) topRightActionOrder() []string { if u.isVaultLocked() { return nil diff --git a/internal/appui/header/layout/dropdown.go b/internal/appui/header/layout/dropdown.go index 25bb94e..8e19512 100644 --- a/internal/appui/header/layout/dropdown.go +++ b/internal/appui/header/layout/dropdown.go @@ -64,12 +64,15 @@ func (s DropdownSurface) Draw(gtx layout.Context, anchor DropdownAnchor, menu la } type ActionMetrics struct { - RowOriginX int - Spacing int - RowDims layout.Dimensions - SyncDims layout.Dimensions - LockDims layout.Dimensions - MainDims layout.Dimensions + RowOriginX int + Spacing int + SyncInnerSpacing int + RowDims layout.Dimensions + SyncDims layout.Dimensions + SyncPrimaryDims layout.Dimensions + SyncToggleDims layout.Dimensions + LockDims layout.Dimensions + MainDims layout.Dimensions } func (m ActionMetrics) SyncAnchor() DropdownAnchor { @@ -86,3 +89,28 @@ func (m ActionMetrics) MainAnchor() DropdownAnchor { TriggerBottomY: m.RowDims.Size.Y, } } + +type ActionBounds struct { + SyncPrimary image.Rectangle + SyncToggle image.Rectangle + Lock image.Rectangle + MainMenu image.Rectangle +} + +func (m ActionMetrics) Bounds() ActionBounds { + top := 0 + syncLeft := m.RowOriginX + syncPrimary := image.Rect(syncLeft, top, syncLeft+m.SyncPrimaryDims.Size.X, top+m.SyncPrimaryDims.Size.Y) + syncToggleLeft := syncPrimary.Max.X + m.SyncInnerSpacing + syncToggle := image.Rect(syncToggleLeft, top, syncToggleLeft+m.SyncToggleDims.Size.X, top+m.SyncToggleDims.Size.Y) + lockLeft := syncLeft + m.SyncDims.Size.X + m.Spacing + lock := image.Rect(lockLeft, top, lockLeft+m.LockDims.Size.X, top+m.LockDims.Size.Y) + mainLeft := lock.Max.X + m.Spacing + mainMenu := image.Rect(mainLeft, top, mainLeft+m.MainDims.Size.X, top+m.MainDims.Size.Y) + return ActionBounds{ + SyncPrimary: syncPrimary, + SyncToggle: syncToggle, + Lock: lock, + MainMenu: mainMenu, + } +} diff --git a/internal/appui/header_sync_menu.go b/internal/appui/header_sync_menu.go index c077e90..b3053f9 100644 --- a/internal/appui/header_sync_menu.go +++ b/internal/appui/header_sync_menu.go @@ -15,19 +15,29 @@ import ( ) func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions { + group, _, _ := u.syncButtonGroupWithMetrics(gtx) + return group +} + +func (u *ui) syncButtonGroupWithMetrics(gtx layout.Context) (layout.Dimensions, layout.Dimensions, layout.Dimensions) { spacing := unit.Dp(4) if u.usesCompactViewport() { spacing = unit.Dp(3) } - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + var primaryDims layout.Dimensions + var toggleDims layout.Dimensions + groupDims := layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, "Sync", u.usesCompactViewport()) + primaryDims = syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, "Sync", u.usesCompactViewport()) + return primaryDims }), layout.Rigid(layout.Spacer{Width: spacing}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return u.syncMenuToggle(gtx) + toggleDims = u.syncMenuToggle(gtx) + return toggleDims }), ) + return groupDims, primaryDims, toggleDims } func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions { diff --git a/internal/appui/main_test.go b/internal/appui/main_test.go index b745dcf..8ded18e 100644 --- a/internal/appui/main_test.go +++ b/internal/appui/main_test.go @@ -5460,6 +5460,28 @@ func TestUISyncDefaultsPersistInSettings(t *testing.T) { } } +func TestUIDebugHeaderBoundsPersistInSettings(t *testing.T) { + t.Parallel() + + configPath := filepath.Join(t.TempDir(), "settings.json") + + first := newUIWithSession("phone", &session.Manager{}, statePaths{ + SettingsPath: configPath, + }) + first.debugLogHeaderBounds = true + first.saveSettings() + + second := newUIWithSession("phone", &session.Manager{}, statePaths{ + SettingsPath: configPath, + }) + second.debugLogHeaderBounds = false + second.loadSettings() + + if !second.debugLogHeaderBounds { + t.Fatal("debugLogHeaderBounds = false, want true after reload") + } +} + func TestUILoadSettingsFallsBackToLegacySyncDefaultsInUIPreferences(t *testing.T) { t.Parallel() @@ -5552,6 +5574,39 @@ func TestUISaveSecuritySettingsPersistsSyncDefaults(t *testing.T) { } } +func TestUISaveSecuritySettingsPersistsDebugHeaderBounds(t *testing.T) { + t.Parallel() + + manager := &session.Manager{} + dir := t.TempDir() + u := newUIWithSession("phone", manager, statePaths{ + DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), + SettingsPath: filepath.Join(dir, "settings.json"), + UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), + }) + u.masterPassword.SetText("correct horse battery staple") + if err := u.createVaultAction(); err != nil { + t.Fatalf("createVaultAction() error = %v", err) + } + u.securityCipher.SetText(vault.CipherAES256) + u.securityKDF.SetText(vault.KDFAES) + u.loadSettingsDraft() + u.settingsDebugHeaderBounds.Value = true + + if err := u.saveSecuritySettingsAction(); err != nil { + t.Fatalf("saveSecuritySettingsAction() error = %v", err) + } + + reloaded := newUIWithSession("phone", &session.Manager{}, statePaths{ + SettingsPath: u.settingsPath, + }) + reloaded.loadSettings() + + if !reloaded.debugLogHeaderBounds { + t.Fatal("reloaded debugLogHeaderBounds = false, want true") + } +} + func TestUIAccessibilityPreferencesPersist(t *testing.T) { t.Parallel() diff --git a/internal/appui/settings.go b/internal/appui/settings.go index ebfa108..9243c93 100644 --- a/internal/appui/settings.go +++ b/internal/appui/settings.go @@ -5,6 +5,7 @@ import ( "fmt" "image" "image/color" + "log" "os" "path/filepath" "strings" @@ -35,7 +36,8 @@ const ( type accessibilityPreferences = settingsmodel.AccessibilityPreferences type settingsFile struct { - Sync syncSettings `json:"sync,omitempty"` + Sync syncSettings `json:"sync,omitempty"` + Debug debugSettings `json:"debug,omitempty"` } type syncSettings struct { @@ -43,6 +45,10 @@ type syncSettings struct { DirectionDefault string `json:"directionDefault,omitempty"` } +type debugSettings struct { + LogHeaderBounds bool `json:"logHeaderBounds,omitempty"` +} + type syncSettingsDraft struct { SourceDefault syncSourceMode DirectionDefault syncDirection @@ -51,6 +57,7 @@ type syncSettingsDraft struct { type settingsDraft struct { Accessibility accessibilityPreferences Sync syncSettingsDraft + Debug debugSettings } type legacySyncPreferences struct { @@ -191,7 +198,11 @@ func (u *ui) loadSettingsDraft() { SourceDefault: u.syncDefaultSourceMode, DirectionDefault: u.syncDefaultDirection, }, + Debug: debugSettings{ + LogHeaderBounds: u.debugLogHeaderBounds, + }, } + u.settingsDebugHeaderBounds.Value = u.settingsDraft.Debug.LogHeaderBounds } func (u *ui) saveSecuritySettingsAction() error { @@ -213,9 +224,14 @@ func (u *ui) applySecuritySettingsLive() error { if u.settingsDraft.Accessibility.DisplayDensity == displayDensityForDenseLayout(u.denseLayout) { u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value) } + u.settingsDraft.Debug.LogHeaderBounds = u.settingsDebugHeaderBounds.Value u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault) u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault) + u.debugLogHeaderBounds = u.settingsDraft.Debug.LogHeaderBounds + if !u.debugLogHeaderBounds { + u.lastHeaderBoundsLog = "" + } u.applySettingsFormToPreferences() u.applyAccessibilityPreferences(u.settingsDraft.Accessibility) u.saveSettings() @@ -234,6 +250,7 @@ func (u *ui) loadSettings() { if json.Unmarshal(content, &settings) == nil { u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault)) u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault)) + u.debugLogHeaderBounds = settings.Debug.LogHeaderBounds return } } @@ -270,6 +287,9 @@ func (u *ui) saveSettings() { SourceDefault: string(u.syncDefaultSourceMode), DirectionDefault: string(u.syncDefaultDirection), }, + Debug: debugSettings{ + LogHeaderBounds: u.debugLogHeaderBounds, + }, }, "", " ") if err != nil { return @@ -277,6 +297,18 @@ func (u *ui) saveSettings() { _ = 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 + } + log.Print(line) + u.lastHeaderBoundsLog = line +} + func (u *ui) showStatusMessage(message string) { u.state.StatusMessage = message if u.accessibilityPrefs.ReducedMotion {