Local-first remote sync and cross-platform UI parity #2

Merged
joejulian merged 53 commits from feature/local-first-remote-sync into main 2026-04-11 06:15:47 +00:00
3 changed files with 55 additions and 26 deletions
Showing only changes of commit 54f13d352c - Show all commits
+5
View File
@@ -15,6 +15,7 @@ import (
"gioui.org/io/key" "gioui.org/io/key"
"gioui.org/io/pointer" "gioui.org/io/pointer"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip" "gioui.org/op/clip"
"gioui.org/op/paint" "gioui.org/op/paint"
"gioui.org/unit" "gioui.org/unit"
@@ -490,6 +491,10 @@ type ui struct {
apiTokenSecret string apiTokenSecret string
phoneSyncMenuAnchor image.Point phoneSyncMenuAnchor image.Point
phoneMainMenuAnchor image.Point phoneMainMenuAnchor image.Point
phoneSyncMenuOrigin image.Point
phoneMainMenuOrigin image.Point
phoneSyncMenuCall op.CallOp
phoneMainMenuCall op.CallOp
phoneSyncMenuVisible bool phoneSyncMenuVisible bool
phoneMainMenuVisible bool phoneMainMenuVisible bool
selectedAuditIndex int selectedAuditIndex int
+6 -1
View File
@@ -3,6 +3,7 @@ package appui
import ( import (
"errors" "errors"
"fmt" "fmt"
"image"
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
@@ -590,6 +591,10 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
paint.FillShape(gtx.Ops, bgColor, clip.Rect{Max: gtx.Constraints.Max}.Op()) paint.FillShape(gtx.Ops, bgColor, clip.Rect{Max: gtx.Constraints.Max}.Op())
u.phoneSyncMenuVisible = false u.phoneSyncMenuVisible = false
u.phoneMainMenuVisible = false u.phoneMainMenuVisible = false
u.phoneSyncMenuOrigin = image.Point{}
u.phoneMainMenuOrigin = image.Point{}
u.phoneSyncMenuCall = op.CallOp{}
u.phoneMainMenuCall = op.CallOp{}
u.syncHostedAPI() u.syncHostedAPI()
u.filter() u.filter()
u.processShortcuts(gtx) u.processShortcuts(gtx)
@@ -615,7 +620,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
layout.Stacked(u.securityDialogOverlay), layout.Stacked(u.securityDialogOverlay),
layout.Stacked(u.remotePrefsDialogOverlay), layout.Stacked(u.remotePrefsDialogOverlay),
layout.Stacked(u.approvalDialogOverlay), layout.Stacked(u.approvalDialogOverlay),
layout.Stacked(func(gtx layout.Context) layout.Dimensions { layout.Expanded(func(gtx layout.Context) layout.Dimensions {
return u.phoneHeaderMenus(gtx) return u.phoneHeaderMenus(gtx)
}), }),
layout.Stacked(u.statusToast), layout.Stacked(u.statusToast),
+44 -25
View File
@@ -12,6 +12,7 @@ import (
"gioui.org/widget/material" "gioui.org/widget/material"
headerview "git.julianfamily.org/keepassgo/internal/appui/header" headerview "git.julianfamily.org/keepassgo/internal/appui/header"
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout" headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
"git.julianfamily.org/keepassgo/internal/appui/platform"
) )
func (u *ui) header(gtx layout.Context) layout.Dimensions { func (u *ui) header(gtx layout.Context) layout.Dimensions {
@@ -41,25 +42,34 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
} }
cluster := u.newHeaderActionCluster(gtx) cluster := u.newHeaderActionCluster(gtx)
surface := headerlayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0} surface := headerlayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0}
rowDims := cluster.layout(gtx, u)
rowDims := layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
cluster.RowCall.Add(gtx.Ops)
return cluster.Metrics.RowDims
})
if u.usesCompactViewport() { if u.usesCompactViewport() {
u.maybeLogHeaderBounds(newHeaderButtonBounds(image.Pt(u.frameInsetPx, u.frameInsetPx), cluster.Metrics.Bounds())) u.maybeLogHeaderBounds(newHeaderButtonBounds(image.Pt(u.frameInsetPx, u.frameInsetPx), cluster.Metrics.Bounds()))
} }
if u.usesCompactViewport() { if u.usesCompactViewport() {
compactSurface := headerlayout.DropdownSurface{
ContainerWidth: gtx.Constraints.Max.X,
LeftInset: u.frameInsetPx,
TopInset: u.frameInsetPx,
}
if u.syncMenuOpen { if u.syncMenuOpen {
u.phoneSyncMenuVisible = true u.phoneSyncMenuVisible = true
u.phoneSyncMenuAnchor = cluster.Metrics.SyncAnchor().Point() u.phoneSyncMenuAnchor = cluster.Metrics.SyncAnchor().Point()
u.maybeLogHeaderMenuToggle("sync-visible", true) u.maybeLogHeaderMenuToggle("sync-visible", true)
placement, menuCall := compactSurface.Place(gtx, cluster.Metrics.SyncAnchor(), u.syncMenu)
u.phoneSyncMenuOrigin = placement.Origin
u.phoneSyncMenuCall = menuCall
u.maybeLogHeaderMenuPlacement("sync-phone", compactSurface, placement)
} }
if u.mainMenuOpen { if u.mainMenuOpen {
u.phoneMainMenuVisible = true u.phoneMainMenuVisible = true
u.phoneMainMenuAnchor = cluster.Metrics.MainAnchor().Point() u.phoneMainMenuAnchor = cluster.Metrics.MainAnchor().Point()
u.maybeLogHeaderMenuToggle("main-visible", true) u.maybeLogHeaderMenuToggle("main-visible", true)
placement, menuCall := compactSurface.Place(gtx, cluster.Metrics.MainAnchor(), u.mainMenu)
u.phoneMainMenuOrigin = placement.Origin
u.phoneMainMenuCall = menuCall
u.maybeLogHeaderMenuPlacement("main-phone", compactSurface, placement)
} }
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, rowDims.Size.Y)} return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, rowDims.Size.Y)}
} }
@@ -84,7 +94,6 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
type headerActionCluster struct { type headerActionCluster struct {
Metrics headerlayout.ActionMetrics Metrics headerlayout.ActionMetrics
RowCall op.CallOp
SyncMenu layout.Widget SyncMenu layout.Widget
MainMenu layout.Widget MainMenu layout.Widget
} }
@@ -103,13 +112,20 @@ func (u *ui) newHeaderActionCluster(gtx layout.Context) headerActionCluster {
if !u.usesCompactViewport() { if !u.usesCompactViewport() {
cluster.Metrics.SyncInnerSpacing = gtx.Dp(unit.Dp(4)) cluster.Metrics.SyncInnerSpacing = gtx.Dp(unit.Dp(4))
} }
rowOps := op.Record(gtx.Ops)
cluster.Metrics.RowDims = cluster.layoutRow(gtx, u)
cluster.RowCall = rowOps.Stop()
cluster.Metrics.RowOriginX = max(0, gtx.Constraints.Max.X-cluster.Metrics.RowDims.Size.X)
return cluster return cluster
} }
func (c *headerActionCluster) layout(gtx layout.Context, u *ui) layout.Dimensions {
rowOps := op.Record(gtx.Ops)
c.Metrics.RowDims = c.layoutRow(gtx, u)
rowCall := rowOps.Stop()
c.Metrics.RowOriginX = max(0, gtx.Constraints.Max.X-c.Metrics.RowDims.Size.X)
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
rowCall.Add(gtx.Ops)
return c.Metrics.RowDims
})
}
func (c *headerActionCluster) layoutRow(gtx layout.Context, u *ui) layout.Dimensions { func (c *headerActionCluster) layoutRow(gtx layout.Context, u *ui) 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 {
@@ -169,29 +185,32 @@ func (u *ui) topRightActionOrder() []string {
} }
func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions {
if u.debugLogHeaderBounds {
platform.LogInfo("KeePassGO", fmt.Sprintf(
"keepassgo phone-header-menus compact=%t syncVisible=%t syncOpen=%t mainVisible=%t mainOpen=%t syncCall=%t mainCall=%t max=%dx%d",
u.usesCompactViewport(),
u.phoneSyncMenuVisible,
u.syncMenuOpen,
u.phoneMainMenuVisible,
u.mainMenuOpen,
u.phoneSyncMenuCall != (op.CallOp{}),
u.phoneMainMenuCall != (op.CallOp{}),
gtx.Constraints.Max.X,
gtx.Constraints.Max.Y,
))
}
if !u.usesCompactViewport() || (!u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone()) { if !u.usesCompactViewport() || (!u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone()) {
return layout.Dimensions{} return layout.Dimensions{}
} }
gtx.Constraints.Min = gtx.Constraints.Max
contentInsetPx := gtx.Dp(unit.Dp(16))
surface := headerlayout.DropdownSurface{
ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)),
LeftInset: contentInsetPx,
TopInset: contentInsetPx,
}
if u.syncMenuVisibleOnPhone() { if u.syncMenuVisibleOnPhone() {
placement, menuCall := surface.Place(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu) stack := op.Offset(u.phoneSyncMenuOrigin).Push(gtx.Ops)
u.maybeLogHeaderMenuPlacement("sync-phone", surface, placement) u.phoneSyncMenuCall.Add(gtx.Ops)
stack := op.Offset(placement.Origin).Push(gtx.Ops)
menuCall.Add(gtx.Ops)
stack.Pop() stack.Pop()
} }
if u.mainMenuVisibleOnPhone() { if u.mainMenuVisibleOnPhone() {
placement, menuCall := surface.Place(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu) stack := op.Offset(u.phoneMainMenuOrigin).Push(gtx.Ops)
u.maybeLogHeaderMenuPlacement("main-phone", surface, placement) u.phoneMainMenuCall.Add(gtx.Ops)
stack := op.Offset(placement.Origin).Push(gtx.Ops)
menuCall.Add(gtx.Ops)
stack.Pop() stack.Pop()
} }
return layout.Dimensions{Size: gtx.Constraints.Max} return layout.Dimensions{Size: gtx.Constraints.Max}