From 07a071503abfae5f185a748807a8b01c63b6bc49 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Wed, 8 Apr 2026 23:49:07 -0700 Subject: [PATCH] Use viewport width for adaptive layout --- app.go | 59 +++++++++++++++++++++++++--------------- main_test.go | 61 ++++++++++++++++++++++++++++++++++++++++++ ui_branding.go | 4 +-- ui_forms.go | 10 +++---- ui_layout_header.go | 18 ++++++------- ui_layout_lifecycle.go | 2 +- 6 files changed, 115 insertions(+), 39 deletions(-) diff --git a/app.go b/app.go index 1f70b59..f32b357 100644 --- a/app.go +++ b/app.go @@ -453,6 +453,8 @@ type ui struct { splitBase float32 splitStartY float32 phoneSpan int + compactViewport bool + viewportMeasured bool phoneGroupBrowserExpanded bool eyeIcon *widget.Icon eyeOffIcon *widget.Icon @@ -3896,16 +3898,28 @@ func (u *ui) lifecycleBusy() bool { return u.shouldShowLifecycleSetup() && strings.TrimSpace(u.loadingMessage) != "" } +func (u *ui) updateViewportLayoutMode(gtx layout.Context) { + u.viewportMeasured = true + u.compactViewport = gtx.Constraints.Max.X < gtx.Dp(unit.Dp(720)) +} + +func (u *ui) usesCompactViewport() bool { + if u.viewportMeasured { + return u.compactViewport + } + return u.mode == "phone" +} + func (u *ui) shouldUseLockedSinglePane() bool { return u.isVaultLocked() && !u.shouldShowLifecycleSetup() } func (u *ui) shouldShowDesktopWorkingHeader() bool { - return u.mode == "desktop" && !u.shouldShowLifecycleSetup() && !u.isVaultLocked() + return !u.usesCompactViewport() && !u.shouldShowLifecycleSetup() && !u.isVaultLocked() } func (u *ui) shouldUseCompactPhoneDetailPane() bool { - if u.mode != "phone" { + if !u.usesCompactViewport() { return false } if u.isVaultLocked() || u.editingEntry { @@ -4044,7 +4058,7 @@ func (u *ui) ensureNavClickables() { } func (u *ui) syncPhoneGroupBrowser(path []string) { - if u.mode != "phone" { + if !u.usesCompactViewport() { return } u.phoneGroupBrowserExpanded = len(u.displayEntryPath(path)) == 0 @@ -4651,6 +4665,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { if _, changed := u.search.Update(gtx); changed { u.filter() } + u.updateViewportLayoutMode(gtx) inset := layout.UniformInset(unit.Dp(16)) return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { @@ -4688,7 +4703,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { if u.shouldUseLockedSinglePane() { return u.detailPanel(gtx) } - if u.mode == "phone" || gtx.Constraints.Max.X < gtx.Dp(unit.Dp(720)) { + if u.usesCompactViewport() { u.phoneSpan = gtx.Constraints.Max.Y listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value) if listHeight < gtx.Dp(unit.Dp(180)) { @@ -5053,7 +5068,7 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions { layout.Spacer{Height: unit.Dp(8)}.Layout, labeledEditorHelp(u.theme, "Package rules", "One rule per line, for example `com.android.chrome=hostname` or `org.keepassgo.browser=view-id`.", &u.autofillPackageRules, false), func(gtx layout.Context) layout.Dimensions { - if u.mode == "phone" { + if u.usesCompactViewport() { return layout.Dimensions{} } return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, @@ -5307,7 +5322,7 @@ func aboutFact(theme *material.Theme, title, primary, secondary string) layout.W } func (u *ui) sectionSpacing() unit.Dp { - if u.mode == "phone" { + if u.usesCompactViewport() { if u.denseLayout { return unit.Dp(4) } @@ -5334,7 +5349,7 @@ func (u *ui) entryRowMetrics() (unit.Dp, unit.Sp, unit.Sp, unit.Sp, unit.Sp, uni pathSize = unit.Sp(10) dividerGap = unit.Dp(5) } - if u.mode == "phone" { + if u.usesCompactViewport() { inset = unit.Dp(9) titleSize = unit.Sp(15) metaSize = unit.Sp(12) @@ -5377,7 +5392,7 @@ func (u *ui) listPanelSearchRow(gtx layout.Context) layout.Dimensions { if u.state.Section == appstate.SectionAbout { return layout.Dimensions{} } - if u.mode == "phone" { + if u.usesCompactViewport() { gtx.Constraints.Min.X = gtx.Constraints.Max.X } return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions { @@ -5398,7 +5413,7 @@ func (u *ui) listPanelPrimaryActionRow(gtx layout.Context) layout.Dimensions { switch u.state.Section { case appstate.SectionEntries: label := "Add Entry" - if u.mode == "phone" { + if u.usesCompactViewport() { label = "+ " + label } btn := material.Button(u.theme, &u.addEntry, label) @@ -5413,11 +5428,11 @@ func (u *ui) listPanelPrimaryActionRow(gtx layout.Context) layout.Dimensions { func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { panel := card spacing := u.sectionSpacing() - if u.mode == "phone" { + if u.usesCompactViewport() { panel = compactCard } u.ensureNavClickables() - if u.mode == "phone" { + if u.usesCompactViewport() { return panel(gtx, func(gtx layout.Context) layout.Dimensions { visibleEntries, entryClicks := u.visibleEntrySnapshot() rows := make([]layout.Widget, 0, 16+len(visibleEntries)) @@ -5561,7 +5576,7 @@ func (u *ui) sectionBar(gtx layout.Context) layout.Dimensions { {click: &u.showAPITokens, label: "API Tokens", compact: "Tokens", active: u.state.Section == appstate.SectionAPITokens}, {click: &u.showAPIAudit, label: "API Audit", compact: "Audit", active: u.state.Section == appstate.SectionAPIAudit}, } - if u.mode == "phone" { + if u.usesCompactViewport() { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceBetween}.Layout(gtx, @@ -5713,7 +5728,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item } func (u *ui) phoneSlider(gtx layout.Context) layout.Dimensions { - if u.mode != "phone" { + if !u.usesCompactViewport() { return layout.Dimensions{} } for { @@ -5759,7 +5774,7 @@ func (u *ui) phoneSlider(gtx layout.Context) layout.Dimensions { func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions { panel := card - if u.mode == "phone" { + if u.usesCompactViewport() { panel = compactCard } return panel(gtx, func(gtx layout.Context) layout.Dimensions { @@ -5874,7 +5889,7 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions { sectionGap = unit.Dp(4) cardGap = unit.Dp(6) } - if u.mode == "phone" { + if u.usesCompactViewport() { titleSize = unit.Sp(18) titlePad = unit.Dp(4) sectionGap = unit.Dp(4) @@ -5898,7 +5913,7 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions { layout.Spacer{Height: titlePad}.Layout, func(gtx layout.Context) layout.Dimensions { if u.state.Section != appstate.SectionRecycleBin { - if u.mode == "phone" { + if u.usesCompactViewport() { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Flexed(0.5, func(gtx layout.Context) layout.Dimensions { return compactTonedButton(gtx, u.theme, &u.copyUser, "Copy Username") @@ -5955,7 +5970,7 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions { layout.Rigid(u.passwordLine("Password", password)), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.mode == "phone" { + if u.usesCompactViewport() { return compactTonedButton(gtx, u.theme, &u.copyURL, "Copy URL") } return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, @@ -6478,7 +6493,7 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.breadcrumbs[index], label) btn.Background, btn.Color = buttonFocusColors(u.accessibilityPrefs, u.isFocused(breadcrumbFocusID(index))) btn.TextSize = unit.Sp(11) - if u.mode == "phone" { + if u.usesCompactViewport() { btn.TextSize = unit.Sp(9) btn.Inset = layout.Inset{Top: 3, Bottom: 3, Left: 6, Right: 6} } else { @@ -6491,7 +6506,7 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "/") lbl.Color = mutedColor inset := unit.Dp(6) - if u.mode == "phone" { + if u.usesCompactViewport() { inset = unit.Dp(4) } return layout.UniformInset(inset).Layout(gtx, lbl.Layout) @@ -6515,7 +6530,7 @@ func (u *ui) visibleBreadcrumbs(displayPath []string) ([]string, []int) { return indices }() } - if u.mode != "phone" || len(displayPath) <= 2 { + if !u.usesCompactViewport() || len(displayPath) <= 2 { crumbs := append([]string{"/"}, append([]string{}, displayPath...)...) indices := make([]int, 0, len(crumbs)) indices = append(indices, 0) @@ -6535,7 +6550,7 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions { u.groupClicks = make([]widget.Clickable, len(groups)) } return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { - if u.mode == "phone" { + if u.usesCompactViewport() { if len(u.displayPath()) == 0 { u.phoneGroupBrowserExpanded = true } @@ -6562,7 +6577,7 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} } maxGroupListHeight := 200 - if u.mode == "phone" { + if u.usesCompactViewport() { maxGroupListHeight = 96 } maxY := gtx.Dp(unit.Dp(maxGroupListHeight)) diff --git a/main_test.go b/main_test.go index a8427a7..31e5dcf 100644 --- a/main_test.go +++ b/main_test.go @@ -1334,6 +1334,67 @@ func TestUIPhoneBackClosesSettingsDialog(t *testing.T) { } } +func TestUIWidePhoneViewportUsesDesktopLayout(t *testing.T) { + t.Parallel() + + u := newUIWithModel("phone", vault.Model{ + Entries: []vault.Entry{{ID: "entry-1", Title: "Vault Console"}}, + }) + u.state.ShowSection(appstate.SectionEntries) + u.updateViewportLayoutMode(layout.Context{ + Ops: new(op.Ops), + Constraints: layout.Exact(image.Pt(1200, 900)), + Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, + }) + + if u.usesCompactViewport() { + t.Fatal("usesCompactViewport() = true, want false for wide phone viewport") + } + if !u.shouldShowDesktopWorkingHeader() { + t.Fatal("shouldShowDesktopWorkingHeader() = false, want true for wide phone viewport") + } +} + +func TestUINarrowDesktopViewportUsesCompactLayout(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ID: "entry-1", Title: "Vault Console"}}, + }) + u.state.ShowSection(appstate.SectionEntries) + u.updateViewportLayoutMode(layout.Context{ + Ops: new(op.Ops), + Constraints: layout.Exact(image.Pt(540, 900)), + Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, + }) + + if !u.usesCompactViewport() { + t.Fatal("usesCompactViewport() = false, want true for narrow desktop viewport") + } + if u.shouldShowDesktopWorkingHeader() { + t.Fatal("shouldShowDesktopWorkingHeader() = true, want false for narrow desktop viewport") + } +} + +func TestUIWidePhoneViewportKeepsAndroidBackBehavior(t *testing.T) { + t.Parallel() + + u := newUIWithModel("phone", vault.Model{}) + u.securityDialogOpen = true + u.updateViewportLayoutMode(layout.Context{ + Ops: new(op.Ops), + Constraints: layout.Exact(image.Pt(1200, 900)), + Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, + }) + + if !u.handlePhoneBack() { + t.Fatal("handlePhoneBack() = false, want true for wide phone viewport") + } + if u.securityDialogOpen { + t.Fatal("securityDialogOpen = true after back, want false") + } +} + func TestUISecurityDialogContentDoesNotPanicWithSmallViewport(t *testing.T) { t.Parallel() diff --git a/ui_branding.go b/ui_branding.go index 6ab5804..d9da84a 100644 --- a/ui_branding.go +++ b/ui_branding.go @@ -10,14 +10,14 @@ import ( ) func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions { - if u.mode != "phone" { + if !u.usesCompactViewport() { return layout.Dimensions{} } return layout.Dimensions{} } func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions { - if u.mode == "phone" { + if u.usesCompactViewport() { return u.brandImage(gtx, u.splashSquare, widthDP, heightDP) } return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP) diff --git a/ui_forms.go b/ui_forms.go index b057199..8110614 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -226,7 +226,7 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { } func (u *ui) shouldPrioritizeLifecyclePrimaryActions() bool { - return u.mode == "phone" + return u.usesCompactViewport() } func (u *ui) selectedRemoteConnectionCard(gtx layout.Context) layout.Dimensions { @@ -938,7 +938,7 @@ func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions { label := "Group Tools" size := unit.Sp(12) - if u.mode == "phone" { + if u.usesCompactViewport() { size = unit.Sp(11) } lbl := material.Label(u.theme, size, label) @@ -948,7 +948,7 @@ func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions { ) }) } - if u.mode == "phone" { + if u.usesCompactViewport() { return content(gtx) } return compactCard(gtx, content) @@ -1013,7 +1013,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 { - if u.mode == "phone" { + if u.usesCompactViewport() { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password") @@ -1072,7 +1072,7 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { layout.Rigid(labeledEditor(u.theme, "Export Attachment Path", &u.exportAttachmentPath, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.mode == "phone" { + if u.usesCompactViewport() { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.addAttachment, "Add Attachment") diff --git a/ui_layout_header.go b/ui_layout_header.go index 8becd22..7fabfa2 100644 --- a/ui_layout_header.go +++ b/ui_layout_header.go @@ -13,7 +13,7 @@ import ( ) func (u *ui) header(gtx layout.Context) layout.Dimensions { - if u.mode == "phone" { + if u.usesCompactViewport() { if u.shouldShowLifecycleSetup() || u.isVaultLocked() { return layout.Dimensions{} } @@ -69,7 +69,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { metrics.RowDims = row(gtx) rowCall := rowOps.Stop() - if u.mode == "phone" { + if u.usesCompactViewport() { metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X) } @@ -79,7 +79,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { rowCall.Add(gtx.Ops) rowStack.Pop() - if u.mode == "phone" { + if u.usesCompactViewport() { if u.syncMenuOpen { u.phoneSyncMenuVisible = true u.phoneSyncMenuAnchor = metrics.syncAnchor().point() @@ -155,13 +155,13 @@ func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions { func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions { label := "Sync" spacing := unit.Dp(4) - if u.mode == "phone" { + if u.usesCompactViewport() { spacing = unit.Dp(3) } row := func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, label, u.mode == "phone") + return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, label, u.usesCompactViewport()) }), layout.Rigid(layout.Spacer{Width: spacing}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -183,7 +183,7 @@ func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions { } btn.Size = unit.Dp(18) btn.Inset = layout.UniformInset(unit.Dp(8)) - if u.mode == "phone" { + if u.usesCompactViewport() { btn.Size = unit.Dp(16) btn.Inset = layout.UniformInset(unit.Dp(7)) } @@ -460,7 +460,7 @@ func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions { } func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { - if u.mode != "phone" { + if !u.usesCompactViewport() { return layout.Dimensions{} } if !u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone() { @@ -484,11 +484,11 @@ func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { } func (u *ui) syncMenuVisibleOnPhone() bool { - return u.mode == "phone" && u.phoneSyncMenuVisible && u.syncMenuOpen + return u.usesCompactViewport() && u.phoneSyncMenuVisible && u.syncMenuOpen } func (u *ui) mainMenuVisibleOnPhone() bool { - return u.mode == "phone" && u.phoneMainMenuVisible && u.mainMenuOpen + return u.usesCompactViewport() && u.phoneMainMenuVisible && u.mainMenuOpen } func (u *ui) syncMenuDropsBelowTrigger() bool { diff --git a/ui_layout_lifecycle.go b/ui_layout_lifecycle.go index f21f680..ecca665 100644 --- a/ui_layout_lifecycle.go +++ b/ui_layout_lifecycle.go @@ -8,7 +8,7 @@ import ( func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions { panel := card - if u.mode == "phone" { + if u.usesCompactViewport() { panel = compactCard } return panel(gtx, func(gtx layout.Context) layout.Dimensions {