Extract app UI layout primitives

This commit is contained in:
Joe Julian
2026-04-09 06:53:21 -07:00
parent c3a9c0fddb
commit 6ccff23804
3 changed files with 53 additions and 51 deletions
@@ -1,4 +1,4 @@
package appui package layout
import ( import (
"image" "image"
@@ -8,47 +8,62 @@ import (
"gioui.org/unit" "gioui.org/unit"
) )
type dropdownAnchor struct { func AnchoredMenuX(triggerWidth, menuWidth int) int {
return triggerWidth - menuWidth
}
func AnchoredMenuOriginX(containerWidth, rowOriginX, triggerRightX, menuWidth int) int {
x := rowOriginX + triggerRightX - menuWidth
if x < 0 {
return 0
}
if x+menuWidth > containerWidth {
return max(0, containerWidth-menuWidth)
}
return x
}
type DropdownAnchor struct {
TriggerRightX int TriggerRightX int
TriggerBottomY int TriggerBottomY int
} }
func (a dropdownAnchor) point() image.Point { func (a DropdownAnchor) Point() image.Point {
return image.Pt(a.TriggerRightX, a.TriggerBottomY) return image.Pt(a.TriggerRightX, a.TriggerBottomY)
} }
type dropdownSurface struct { type DropdownSurface struct {
ContainerWidth int ContainerWidth int
LeftInset int LeftInset int
TopInset int TopInset int
} }
func (s dropdownSurface) menuConstraints(gtx layout.Context) layout.Context { func (s DropdownSurface) MenuConstraints(gtx layout.Context) layout.Context {
menuGTX := gtx menuGTX := gtx
menuGTX.Constraints.Min = image.Point{} menuGTX.Constraints.Min = image.Point{}
menuGTX.Constraints.Max.X = max(0, s.ContainerWidth) menuGTX.Constraints.Max.X = max(0, s.ContainerWidth)
return menuGTX return menuGTX
} }
func (s dropdownSurface) origin(anchor dropdownAnchor, menuWidth int) image.Point { func (s DropdownSurface) Origin(anchor DropdownAnchor, menuWidth int) image.Point {
x := s.LeftInset + anchoredMenuOriginX(s.ContainerWidth, 0, anchor.TriggerRightX, menuWidth) x := s.LeftInset + AnchoredMenuOriginX(s.ContainerWidth, 0, anchor.TriggerRightX, menuWidth)
y := s.TopInset + anchor.TriggerBottomY y := s.TopInset + anchor.TriggerBottomY
return image.Pt(x, y) return image.Pt(x, y)
} }
func (s dropdownSurface) draw(gtx layout.Context, anchor dropdownAnchor, menu layout.Widget) layout.Dimensions { func (s DropdownSurface) Draw(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) layout.Dimensions {
menuGTX := s.menuConstraints(gtx) menuGTX := s.MenuConstraints(gtx)
menuOps := op.Record(gtx.Ops) menuOps := op.Record(gtx.Ops)
menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, menu) menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, menu)
menuCall := menuOps.Stop() menuCall := menuOps.Stop()
menuOrigin := s.origin(anchor, menuDims.Size.X) menuOrigin := s.Origin(anchor, menuDims.Size.X)
stack := op.Offset(menuOrigin).Push(gtx.Ops) stack := op.Offset(menuOrigin).Push(gtx.Ops)
menuCall.Add(gtx.Ops) menuCall.Add(gtx.Ops)
stack.Pop() stack.Pop()
return layout.Dimensions{Size: gtx.Constraints.Max} return layout.Dimensions{Size: gtx.Constraints.Max}
} }
type headerActionMetrics struct { type HeaderActionMetrics struct {
RowOriginX int RowOriginX int
Spacing int Spacing int
RowDims layout.Dimensions RowDims layout.Dimensions
@@ -57,16 +72,16 @@ type headerActionMetrics struct {
MainDims layout.Dimensions MainDims layout.Dimensions
} }
func (m headerActionMetrics) syncAnchor() dropdownAnchor { func (m HeaderActionMetrics) SyncAnchor() DropdownAnchor {
return dropdownAnchor{ return DropdownAnchor{
TriggerRightX: m.RowOriginX + m.SyncDims.Size.X, TriggerRightX: m.RowOriginX + m.SyncDims.Size.X,
TriggerBottomY: m.RowDims.Size.Y, TriggerBottomY: m.RowDims.Size.Y,
} }
} }
func (m headerActionMetrics) mainAnchor() dropdownAnchor { func (m HeaderActionMetrics) MainAnchor() DropdownAnchor {
triggerRightX := m.SyncDims.Size.X + m.Spacing + m.LockDims.Size.X + m.Spacing + m.MainDims.Size.X triggerRightX := m.SyncDims.Size.X + m.Spacing + m.LockDims.Size.X + m.Spacing + m.MainDims.Size.X
return dropdownAnchor{ return DropdownAnchor{
TriggerRightX: m.RowOriginX + triggerRightX, TriggerRightX: m.RowOriginX + triggerRightX,
TriggerBottomY: m.RowDims.Size.Y, TriggerBottomY: m.RowDims.Size.Y,
} }
+13 -12
View File
@@ -25,6 +25,7 @@ import (
"git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/appstate" "git.julianfamily.org/keepassgo/internal/appstate"
appuilayout "git.julianfamily.org/keepassgo/internal/appui/layout"
"git.julianfamily.org/keepassgo/internal/clipboard" "git.julianfamily.org/keepassgo/internal/clipboard"
"git.julianfamily.org/keepassgo/internal/passwords" "git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session" "git.julianfamily.org/keepassgo/internal/session"
@@ -372,10 +373,10 @@ func TestUIHeaderMenusUseOverlayModelAcrossModes(t *testing.T) {
func TestAnchoredMenuXAllowsWiderMenusToExtendLeft(t *testing.T) { func TestAnchoredMenuXAllowsWiderMenusToExtendLeft(t *testing.T) {
t.Parallel() t.Parallel()
if got := anchoredMenuX(48, 160); got != -112 { if got := appuilayout.AnchoredMenuX(48, 160); got != -112 {
t.Fatalf("anchoredMenuX(48, 160) = %d, want -112", got) t.Fatalf("anchoredMenuX(48, 160) = %d, want -112", got)
} }
if got := anchoredMenuX(160, 48); got != 112 { if got := appuilayout.AnchoredMenuX(160, 48); got != 112 {
t.Fatalf("anchoredMenuX(160, 48) = %d, want 112", got) t.Fatalf("anchoredMenuX(160, 48) = %d, want 112", got)
} }
} }
@@ -383,10 +384,10 @@ func TestAnchoredMenuXAllowsWiderMenusToExtendLeft(t *testing.T) {
func TestAnchoredMenuOriginXClampsToVisibleContainer(t *testing.T) { func TestAnchoredMenuOriginXClampsToVisibleContainer(t *testing.T) {
t.Parallel() t.Parallel()
if got := anchoredMenuOriginX(360, 312, 360, 140); got != 220 { if got := appuilayout.AnchoredMenuOriginX(360, 312, 360, 140); got != 220 {
t.Fatalf("anchoredMenuOriginX should keep a right-aligned menu visible, got %d want 220", got) t.Fatalf("anchoredMenuOriginX should keep a right-aligned menu visible, got %d want 220", got)
} }
if got := anchoredMenuOriginX(360, 0, 44, 160); got != 0 { if got := appuilayout.AnchoredMenuOriginX(360, 0, 44, 160); got != 0 {
t.Fatalf("anchoredMenuOriginX should clamp oversized left overflow to zero, got %d want 0", got) t.Fatalf("anchoredMenuOriginX should clamp oversized left overflow to zero, got %d want 0", got)
} }
} }
@@ -394,7 +395,7 @@ func TestAnchoredMenuOriginXClampsToVisibleContainer(t *testing.T) {
func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) { func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) {
t.Parallel() t.Parallel()
metrics := headerActionMetrics{ metrics := appuilayout.HeaderActionMetrics{
RowOriginX: 24, RowOriginX: 24,
Spacing: 8, Spacing: 8,
RowDims: layout.Dimensions{Size: image.Pt(180, 40)}, RowDims: layout.Dimensions{Size: image.Pt(180, 40)},
@@ -403,10 +404,10 @@ func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) {
MainDims: layout.Dimensions{Size: image.Pt(36, 40)}, MainDims: layout.Dimensions{Size: image.Pt(36, 40)},
} }
if got := metrics.syncAnchor(); got != (dropdownAnchor{TriggerRightX: 76, TriggerBottomY: 40}) { if got := metrics.SyncAnchor(); got != (appuilayout.DropdownAnchor{TriggerRightX: 76, TriggerBottomY: 40}) {
t.Fatalf("metrics.syncAnchor() = %+v, want right=76 bottom=40", got) t.Fatalf("metrics.syncAnchor() = %+v, want right=76 bottom=40", got)
} }
if got := metrics.mainAnchor(); got != (dropdownAnchor{TriggerRightX: 172, TriggerBottomY: 40}) { if got := metrics.MainAnchor(); got != (appuilayout.DropdownAnchor{TriggerRightX: 172, TriggerBottomY: 40}) {
t.Fatalf("metrics.mainAnchor() = %+v, want right=172 bottom=40", got) t.Fatalf("metrics.mainAnchor() = %+v, want right=172 bottom=40", got)
} }
} }
@@ -414,15 +415,15 @@ func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) {
func TestDropdownSurfaceOriginKeepsMenusWithinVisibleArea(t *testing.T) { func TestDropdownSurfaceOriginKeepsMenusWithinVisibleArea(t *testing.T) {
t.Parallel() t.Parallel()
surface := dropdownSurface{ContainerWidth: 320, LeftInset: 16, TopInset: 16} surface := appuilayout.DropdownSurface{ContainerWidth: 320, LeftInset: 16, TopInset: 16}
anchor := dropdownAnchor{TriggerRightX: 300, TriggerBottomY: 42} anchor := appuilayout.DropdownAnchor{TriggerRightX: 300, TriggerBottomY: 42}
if got := surface.origin(anchor, 140); got != image.Pt(176, 58) { if got := surface.Origin(anchor, 140); got != image.Pt(176, 58) {
t.Fatalf("surface.origin(anchor, 140) = %v, want (176,58)", got) t.Fatalf("surface.origin(anchor, 140) = %v, want (176,58)", got)
} }
leftAnchor := dropdownAnchor{TriggerRightX: 36, TriggerBottomY: 42} leftAnchor := appuilayout.DropdownAnchor{TriggerRightX: 36, TriggerBottomY: 42}
if got := surface.origin(leftAnchor, 120); got != image.Pt(16, 58) { if got := surface.Origin(leftAnchor, 120); got != image.Pt(16, 58) {
t.Fatalf("surface.origin(leftAnchor, 120) = %v, want (16,58)", got) t.Fatalf("surface.origin(leftAnchor, 120) = %v, want (16,58)", got)
} }
} }
+10 -24
View File
@@ -10,6 +10,7 @@ import (
"gioui.org/unit" "gioui.org/unit"
"gioui.org/widget" "gioui.org/widget"
"gioui.org/widget/material" "gioui.org/widget/material"
appuilayout "git.julianfamily.org/keepassgo/internal/appui/layout"
) )
func (u *ui) header(gtx layout.Context) layout.Dimensions { func (u *ui) header(gtx layout.Context) layout.Dimensions {
@@ -44,7 +45,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
return layout.Dimensions{} return layout.Dimensions{}
} }
spacing := gtx.Dp(unit.Dp(8)) spacing := gtx.Dp(unit.Dp(8))
metrics := headerActionMetrics{Spacing: spacing} metrics := appuilayout.HeaderActionMetrics{Spacing: spacing}
row := func(gtx layout.Context) layout.Dimensions { row := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -73,7 +74,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X) metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X)
} }
surface := dropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0} surface := appuilayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0}
rowStack := op.Offset(image.Pt(metrics.RowOriginX, 0)).Push(gtx.Ops) rowStack := op.Offset(image.Pt(metrics.RowOriginX, 0)).Push(gtx.Ops)
rowCall.Add(gtx.Ops) rowCall.Add(gtx.Ops)
@@ -82,21 +83,21 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
if u.usesCompactViewport() { if u.usesCompactViewport() {
if u.syncMenuOpen { if u.syncMenuOpen {
u.phoneSyncMenuVisible = true u.phoneSyncMenuVisible = true
u.phoneSyncMenuAnchor = metrics.syncAnchor().point() u.phoneSyncMenuAnchor = metrics.SyncAnchor().Point()
} }
if u.mainMenuOpen { if u.mainMenuOpen {
u.phoneMainMenuVisible = true u.phoneMainMenuVisible = true
u.phoneMainMenuAnchor = metrics.mainAnchor().point() u.phoneMainMenuAnchor = metrics.MainAnchor().Point()
} }
width := gtx.Constraints.Max.X width := gtx.Constraints.Max.X
return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)} return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)}
} }
if u.syncMenuOpen { if u.syncMenuOpen {
surface.draw(gtx, metrics.syncAnchor(), u.syncMenu) surface.Draw(gtx, metrics.SyncAnchor(), u.syncMenu)
} }
if u.mainMenuOpen { if u.mainMenuOpen {
surface.draw(gtx, metrics.mainAnchor(), u.mainMenu) surface.Draw(gtx, metrics.MainAnchor(), u.mainMenu)
} }
width := metrics.RowDims.Size.X width := metrics.RowDims.Size.X
@@ -468,17 +469,17 @@ func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions {
} }
gtx.Constraints.Min = gtx.Constraints.Max gtx.Constraints.Min = gtx.Constraints.Max
contentInsetPx := gtx.Dp(unit.Dp(16)) contentInsetPx := gtx.Dp(unit.Dp(16))
surface := dropdownSurface{ surface := appuilayout.DropdownSurface{
ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)), ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)),
LeftInset: contentInsetPx, LeftInset: contentInsetPx,
TopInset: contentInsetPx, TopInset: contentInsetPx,
} }
if u.syncMenuVisibleOnPhone() { if u.syncMenuVisibleOnPhone() {
surface.draw(gtx, dropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu) surface.Draw(gtx, appuilayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu)
} }
if u.mainMenuVisibleOnPhone() { if u.mainMenuVisibleOnPhone() {
surface.draw(gtx, dropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu) surface.Draw(gtx, appuilayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu)
} }
return layout.Dimensions{Size: gtx.Constraints.Max} return layout.Dimensions{Size: gtx.Constraints.Max}
} }
@@ -511,21 +512,6 @@ func (u *ui) mainMenuRightAlignsToTrigger() bool {
return true return true
} }
func anchoredMenuX(triggerWidth, menuWidth int) int {
return triggerWidth - menuWidth
}
func anchoredMenuOriginX(containerWidth, rowOriginX, triggerRightX, menuWidth int) int {
x := rowOriginX + triggerRightX - menuWidth
if x < 0 {
return 0
}
if x+menuWidth > containerWidth {
return max(0, containerWidth-menuWidth)
}
return x
}
func menuActionWidth(gtx layout.Context, rows []layout.Widget) int { func menuActionWidth(gtx layout.Context, rows []layout.Widget) int {
width := 0 width := 0
for _, row := range rows { for _, row := range rows {