From fe3c07e3dde6e350d9629e6aa0f886e6de97a0de Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Fri, 10 Apr 2026 22:12:50 -0700 Subject: [PATCH] Unify action menus and collapse empty detail pane --- internal/appui/app.go | 45 +++-------- internal/appui/frame.go | 14 ++++ internal/appui/header.go | 123 +++++++++++++++++------------ internal/appui/header_sync_menu.go | 5 -- 4 files changed, 100 insertions(+), 87 deletions(-) diff --git a/internal/appui/app.go b/internal/appui/app.go index 2e3496d..fa9d237 100644 --- a/internal/appui/app.go +++ b/internal/appui/app.go @@ -1850,8 +1850,17 @@ func (u *ui) navigationHeaderLabel() string { func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item entry) layout.Dimensions { for click.Clicked(gtx) { - _ = u.state.ToggleVisibleIndex(idx) + if !u.shouldShowDetailPane() { + if idx >= 0 && idx < len(u.visible) { + u.state.SelectedEntryID = u.visible[idx].ID + } + } else { + _ = u.state.ToggleVisibleIndex(idx) + } u.loadSelectedEntryIntoEditor() + if u.invalidate != nil { + u.invalidate() + } } return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions { selected := item.ID == u.state.SelectedEntryID @@ -2014,21 +2023,7 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions { return panel(gtx, func(gtx layout.Context) layout.Dimensions { if u.shouldShowDesktopWorkingHeader() { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Alignment: layout.Middle, Spacing: layout.SpaceStart}.Layout(gtx, - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - return layout.Dimensions{} - }), - layout.Rigid(u.syncButtonGroup), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.Button(u.theme, &u.lockVault, "Lock") - return btn.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(u.mainMenuButtonGroup), - ) - }), + layout.Rigid(u.headerActions), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return u.detailPanelContent(gtx) @@ -2049,7 +2044,7 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions { panel := u.staticDetailPanel() return layout.Flex{Axis: layout.Vertical}.Layout(gtx, panel...) case detaillayout.ModeEmpty: - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.emptyDetailChildren()...) + return layout.Dimensions{} } item, ok := u.selectedEntry() if mode == detaillayout.ModeEditor { @@ -2089,22 +2084,6 @@ func (u *ui) staticDetailPanel() []layout.FlexChild { } } -func (u *ui) emptyDetailChildren() []layout.FlexChild { - return []layout.FlexChild{ - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(18), "Entry details") - lbl.Color = accentColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(16), u.detailPlaceholderMessage()) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - } -} - func (u *ui) detailEditorContent(gtx layout.Context, hasSelected bool) layout.Dimensions { rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { diff --git a/internal/appui/frame.go b/internal/appui/frame.go index a731660..1df45a2 100644 --- a/internal/appui/frame.go +++ b/internal/appui/frame.go @@ -20,6 +20,7 @@ import ( "git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/appstate" + detaillayout "git.julianfamily.org/keepassgo/internal/appui/detail/layout" "git.julianfamily.org/keepassgo/internal/clipboard" "git.julianfamily.org/keepassgo/internal/session" ) @@ -1310,6 +1311,8 @@ func (u *ui) primaryContent(gtx layout.Context) layout.Dimensions { return u.lifecycleScreen(gtx) case u.shouldUseLockedSinglePane(): return u.detailPanel(gtx) + case !u.shouldShowDetailPane(): + return u.listPanel(gtx) case u.usesCompactViewport(): return u.compactPrimaryContent(gtx) default: @@ -1317,6 +1320,17 @@ func (u *ui) primaryContent(gtx layout.Context) layout.Dimensions { } } +func (u *ui) shouldShowDetailPane() bool { + _, hasSelectedEntry := u.selectedEntry() + mode := detaillayout.Resolve( + u.isVaultLocked(), + u.state.Section == appstate.SectionAPITokens || u.state.Section == appstate.SectionAPIAudit || u.state.Section == appstate.SectionAbout, + hasSelectedEntry, + u.editingEntry, + ) + return mode != detaillayout.ModeEmpty +} + func (u *ui) compactPrimaryContent(gtx layout.Context) layout.Dimensions { u.phoneSpan = gtx.Constraints.Max.Y listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value) diff --git a/internal/appui/header.go b/internal/appui/header.go index 0e7bd25..97469c2 100644 --- a/internal/appui/header.go +++ b/internal/appui/header.go @@ -36,57 +36,21 @@ func (u *ui) header(gtx layout.Context) layout.Dimensions { } func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { - if u.shouldShowLifecycleSetup() || u.isVaultLocked() || u.shouldShowDesktopWorkingHeader() { + if u.shouldShowLifecycleSetup() || u.isVaultLocked() { return layout.Dimensions{} } cluster := u.newHeaderActionCluster(gtx) - surface := headerlayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0} rowDims := cluster.layout(gtx, u) if u.usesCompactViewport() { u.maybeLogHeaderBounds(newHeaderButtonBounds(image.Pt(u.frameInsetPx, u.frameInsetPx), cluster.Metrics.Bounds())) } if u.usesCompactViewport() { - compactSurface := headerlayout.DropdownSurface{ - ContainerWidth: gtx.Constraints.Max.X, - LeftInset: u.frameInsetPx, - TopInset: u.frameInsetPx, - } - if u.syncMenuOpen { - u.phoneSyncMenuVisible = true - u.maybeLogHeaderMenuToggle("sync-visible", true) - placement, menuCall := compactSurface.Place(gtx, cluster.Metrics.SyncAnchor(), u.syncMenu) - u.phoneSyncMenuOrigin = placement.Origin - u.phoneSyncMenuSize = placement.Size - u.phoneSyncMenuCall = menuCall - u.maybeLogHeaderMenuPlacement("sync-phone", compactSurface, placement) - } - if u.mainMenuOpen { - u.phoneMainMenuVisible = true - u.maybeLogHeaderMenuToggle("main-visible", true) - placement, menuCall := compactSurface.Place(gtx, cluster.Metrics.MainAnchor(), u.mainMenu) - u.phoneMainMenuOrigin = placement.Origin - u.phoneMainMenuSize = placement.Size - u.phoneMainMenuCall = menuCall - u.maybeLogHeaderMenuPlacement("main-phone", compactSurface, placement) - } + cluster.prepareCompactMenus(gtx, u) return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, rowDims.Size.Y)} } - if cluster.ShowSyncMenu() { - placement, menuCall := surface.Place(gtx, cluster.Metrics.SyncAnchor(), cluster.SyncMenu) - u.maybeLogHeaderMenuPlacement("sync", surface, placement) - stack := op.Offset(placement.Origin).Push(gtx.Ops) - menuCall.Add(gtx.Ops) - stack.Pop() - } - if cluster.ShowMainMenu() { - placement, menuCall := surface.Place(gtx, cluster.Metrics.MainAnchor(), cluster.MainMenu) - u.maybeLogHeaderMenuPlacement("main", surface, placement) - stack := op.Offset(placement.Origin).Push(gtx.Ops) - menuCall.Add(gtx.Ops) - stack.Pop() - } + cluster.drawOverlayMenus(gtx, u, headerlayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0}) return rowDims } @@ -125,6 +89,17 @@ func (c *headerActionCluster) layout(gtx layout.Context, u *ui) layout.Dimension }) } +func (c headerActionCluster) activeMenu() layout.Widget { + switch { + case c.ShowSyncMenu(): + return c.SyncMenu + case c.ShowMainMenu(): + return c.MainMenu + default: + return nil + } +} + func (c *headerActionCluster) layoutRow(gtx layout.Context, u *ui) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -145,6 +120,61 @@ func (c *headerActionCluster) layoutRow(gtx layout.Context, u *ui) layout.Dimens ) } +func (c *headerActionCluster) prepareCompactMenus(gtx layout.Context, u *ui) { + compactSurface := headerlayout.DropdownSurface{ + ContainerWidth: gtx.Constraints.Max.X, + LeftInset: u.frameInsetPx, + TopInset: u.frameInsetPx, + } + if c.ShowSyncMenu() { + u.phoneSyncMenuVisible = true + u.maybeLogHeaderMenuToggle("sync-visible", true) + placement, menuCall := compactSurface.Place(gtx, c.Metrics.SyncAnchor(), c.SyncMenu) + u.phoneSyncMenuOrigin = placement.Origin + u.phoneSyncMenuSize = placement.Size + u.phoneSyncMenuCall = menuCall + u.maybeLogHeaderMenuPlacement("sync-phone", compactSurface, placement) + } + if c.ShowMainMenu() { + u.phoneMainMenuVisible = true + u.maybeLogHeaderMenuToggle("main-visible", true) + placement, menuCall := compactSurface.Place(gtx, c.Metrics.MainAnchor(), c.MainMenu) + u.phoneMainMenuOrigin = placement.Origin + u.phoneMainMenuSize = placement.Size + u.phoneMainMenuCall = menuCall + u.maybeLogHeaderMenuPlacement("main-phone", compactSurface, placement) + } +} + +func (c headerActionCluster) drawOverlayMenus(gtx layout.Context, u *ui, surface headerlayout.DropdownSurface) { + if c.ShowSyncMenu() { + placement, menuCall := surface.Place(gtx, c.Metrics.SyncAnchor(), c.SyncMenu) + u.maybeLogHeaderMenuPlacement("sync", surface, placement) + stack := op.Offset(placement.Origin).Push(gtx.Ops) + menuCall.Add(gtx.Ops) + stack.Pop() + } + if c.ShowMainMenu() { + placement, menuCall := surface.Place(gtx, c.Metrics.MainAnchor(), c.MainMenu) + u.maybeLogHeaderMenuPlacement("main", surface, placement) + stack := op.Offset(placement.Origin).Push(gtx.Ops) + menuCall.Add(gtx.Ops) + stack.Pop() + } +} + +func (c headerActionCluster) layoutCompactMenuRow(gtx layout.Context) layout.Dimensions { + menu := c.activeMenu() + if menu == nil { + return layout.Dimensions{} + } + fullWidthGTX := gtx + fullWidthGTX.Constraints.Min = image.Point{} + fullWidthGTX.Constraints.Min.X = fullWidthGTX.Constraints.Max.X + dims := layout.E.Layout(fullWidthGTX, menu) + return layout.Dimensions{Size: image.Pt(fullWidthGTX.Constraints.Max.X, dims.Size.Y)} +} + type headerButtonBounds struct { SyncPrimary image.Rectangle SyncToggle image.Rectangle @@ -188,26 +218,21 @@ func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} } + cluster := u.newHeaderActionCluster(gtx) if u.syncMenuVisibleOnPhone() { return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { stack := op.Offset(image.Pt(0, max(0, u.phoneSyncMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops) defer stack.Pop() - fullWidthGTX := gtx - fullWidthGTX.Constraints.Min = image.Point{} - fullWidthGTX.Constraints.Min.X = fullWidthGTX.Constraints.Max.X - dims := layout.E.Layout(fullWidthGTX, u.syncMenu) - return layout.Dimensions{Size: image.Pt(fullWidthGTX.Constraints.Max.X, max(dims.Size.Y, u.phoneSyncMenuOrigin.Y))} + dims := cluster.layoutCompactMenuRow(gtx) + return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneSyncMenuOrigin.Y))} }) } if u.mainMenuVisibleOnPhone() { return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { stack := op.Offset(image.Pt(0, max(0, u.phoneMainMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops) defer stack.Pop() - fullWidthGTX := gtx - fullWidthGTX.Constraints.Min = image.Point{} - fullWidthGTX.Constraints.Min.X = fullWidthGTX.Constraints.Max.X - dims := layout.E.Layout(fullWidthGTX, u.mainMenu) - return layout.Dimensions{Size: image.Pt(fullWidthGTX.Constraints.Max.X, max(dims.Size.Y, u.phoneMainMenuOrigin.Y))} + dims := cluster.layoutCompactMenuRow(gtx) + return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneMainMenuOrigin.Y))} }) } return layout.Dimensions{} diff --git a/internal/appui/header_sync_menu.go b/internal/appui/header_sync_menu.go index 22b7ed1..5193a30 100644 --- a/internal/appui/header_sync_menu.go +++ b/internal/appui/header_sync_menu.go @@ -16,11 +16,6 @@ import ( "git.julianfamily.org/keepassgo/internal/vault" ) -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() {