336 lines
11 KiB
Go
336 lines
11 KiB
Go
package appui
|
|
|
|
import (
|
|
"fmt"
|
|
"image"
|
|
|
|
"gioui.org/layout"
|
|
"gioui.org/op"
|
|
"gioui.org/op/paint"
|
|
"gioui.org/unit"
|
|
"gioui.org/widget"
|
|
"gioui.org/widget/material"
|
|
headerview "git.julianfamily.org/keepassgo/internal/appui/header"
|
|
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
|
|
)
|
|
|
|
func (u *ui) header(gtx layout.Context) layout.Dimensions {
|
|
if u.usesCompactViewport() {
|
|
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
|
|
return layout.Dimensions{}
|
|
}
|
|
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
|
return u.headerActions(gtx)
|
|
}
|
|
if u.shouldShowDesktopWorkingHeader() {
|
|
return layout.Dimensions{}
|
|
}
|
|
return card(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return u.brandMark(gtx, 196, 56)
|
|
}),
|
|
layout.Flexed(1, u.headerActions),
|
|
)
|
|
})
|
|
}
|
|
|
|
func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
|
|
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
|
|
return layout.Dimensions{}
|
|
}
|
|
cluster := u.newHeaderActionCluster(gtx)
|
|
rowDims := cluster.layout(gtx, u)
|
|
if u.usesCompactViewport() {
|
|
u.maybeLogHeaderBounds(newHeaderButtonBounds(image.Pt(u.frameInsetPx, u.frameInsetPx), cluster.Metrics.Bounds()))
|
|
}
|
|
|
|
if u.usesCompactViewport() {
|
|
cluster.prepareCompactMenus(gtx, u)
|
|
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, rowDims.Size.Y)}
|
|
}
|
|
return rowDims
|
|
}
|
|
|
|
type headerActionCluster struct {
|
|
Metrics headerlayout.ActionMetrics
|
|
SyncMenu layout.Widget
|
|
MainMenu layout.Widget
|
|
}
|
|
|
|
func (c headerActionCluster) ShowSyncMenu() bool { return c.SyncMenu != nil }
|
|
|
|
func (c headerActionCluster) ShowMainMenu() bool { return c.MainMenu != nil }
|
|
|
|
func (u *ui) newHeaderActionCluster(gtx layout.Context) headerActionCluster {
|
|
cluster := headerActionCluster{
|
|
SyncMenu: u.syncMenuWidget(),
|
|
MainMenu: u.mainMenuWidget(),
|
|
}
|
|
spacing := gtx.Dp(unit.Dp(8))
|
|
cluster.Metrics = headerlayout.ActionMetrics{Spacing: spacing, SyncInnerSpacing: gtx.Dp(unit.Dp(3))}
|
|
if !u.usesCompactViewport() {
|
|
cluster.Metrics.SyncInnerSpacing = gtx.Dp(unit.Dp(4))
|
|
}
|
|
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) 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 {
|
|
c.Metrics.SyncDims, c.Metrics.SyncPrimaryDims, c.Metrics.SyncToggleDims = u.syncButtonGroupWithMetrics(gtx)
|
|
return c.Metrics.SyncDims
|
|
}),
|
|
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")
|
|
c.Metrics.LockDims = btn.Layout(gtx)
|
|
return c.Metrics.LockDims
|
|
}),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
c.Metrics.MainDims = u.mainMenuButtonGroup(gtx)
|
|
return c.Metrics.MainDims
|
|
}),
|
|
)
|
|
}
|
|
|
|
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) layoutMenuRow(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
|
|
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
|
|
}
|
|
return []string{"Sync", "Lock", "Menu"}
|
|
}
|
|
|
|
func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions {
|
|
if !u.usesCompactViewport() || (!u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone()) {
|
|
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()
|
|
dims := cluster.layoutMenuRow(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()
|
|
dims := cluster.layoutMenuRow(gtx)
|
|
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneMainMenuOrigin.Y))}
|
|
})
|
|
}
|
|
return layout.Dimensions{}
|
|
}
|
|
|
|
func (u *ui) desktopHeaderMenus(gtx layout.Context) layout.Dimensions {
|
|
if u.usesCompactViewport() || (!u.syncMenuOpen && !u.mainMenuOpen) {
|
|
return layout.Dimensions{}
|
|
}
|
|
cluster := u.newHeaderActionCluster(gtx)
|
|
dims := cluster.layoutMenuRow(gtx)
|
|
if dims.Size.Y == 0 {
|
|
return dims
|
|
}
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return dims }),
|
|
)
|
|
}
|
|
|
|
func (u *ui) syncMenuVisibleOnPhone() bool {
|
|
return u.usesCompactViewport() && u.phoneSyncMenuVisible && u.syncMenuOpen
|
|
}
|
|
|
|
func (u *ui) mainMenuVisibleOnPhone() bool {
|
|
return u.usesCompactViewport() && u.phoneMainMenuVisible && u.mainMenuOpen
|
|
}
|
|
|
|
func (u *ui) syncMenuDropsBelowTrigger() bool { return true }
|
|
|
|
func (u *ui) syncMenuRightAlignsToTrigger() bool { return true }
|
|
|
|
func (u *ui) headerMenusUseOverlayModel() bool { return true }
|
|
|
|
func (u *ui) mainMenuDropsBelowTrigger() bool { return true }
|
|
|
|
func (u *ui) mainMenuRightAlignsToTrigger() bool { return true }
|
|
|
|
func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions {
|
|
if !u.usesCompactViewport() {
|
|
return layout.Dimensions{}
|
|
}
|
|
return layout.Dimensions{}
|
|
}
|
|
|
|
func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions {
|
|
if u.usesCompactViewport() {
|
|
return u.brandImage(gtx, u.splashSquare, widthDP, heightDP)
|
|
}
|
|
return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP)
|
|
}
|
|
|
|
func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP float32) layout.Dimensions {
|
|
width := gtx.Dp(unit.Dp(widthDP))
|
|
height := gtx.Dp(unit.Dp(heightDP))
|
|
if width > gtx.Constraints.Max.X {
|
|
width = gtx.Constraints.Max.X
|
|
}
|
|
if height > gtx.Constraints.Max.Y && gtx.Constraints.Max.Y > 0 {
|
|
height = gtx.Constraints.Max.Y
|
|
}
|
|
img := widget.Image{
|
|
Src: src,
|
|
Fit: widget.Contain,
|
|
Position: layout.W,
|
|
Scale: 1.0 / gtx.Metric.PxPerDp,
|
|
}
|
|
gtx.Constraints.Min = image.Point{}
|
|
gtx.Constraints.Max = image.Pt(width, height)
|
|
return img.Layout(gtx)
|
|
}
|
|
|
|
func (u *ui) mainMenuWidget() layout.Widget {
|
|
if !u.mainMenuOpen {
|
|
return nil
|
|
}
|
|
return u.mainMenu
|
|
}
|
|
|
|
func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions {
|
|
rows := []layout.Widget{
|
|
func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.showEntries, "Entries")
|
|
},
|
|
func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
|
|
},
|
|
func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens")
|
|
},
|
|
func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit")
|
|
},
|
|
func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") },
|
|
func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings")
|
|
},
|
|
}
|
|
return headerview.MainMenu(gtx, u.theme, rows, compactCard, nil)
|
|
}
|
|
|
|
func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions {
|
|
icon := u.menuIcon
|
|
if icon == nil {
|
|
icon = u.settingsIcon
|
|
}
|
|
return headerview.MainMenuButtonGroup(gtx, u.theme, &u.toggleMainMenu, icon, u.mainMenuOpen, selectedColor, accentColor)
|
|
}
|
|
|
|
func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
|
return headerlayout.IntrinsicCompactCard(gtx, w, compactCard, nil)
|
|
}
|
|
|
|
func menuActionWidth(gtx layout.Context, rows []layout.Widget) int {
|
|
return headerlayout.MenuActionWidth(gtx, rows)
|
|
}
|
|
|
|
func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
|
|
return headerlayout.RightAlignedAction(gtx, width, child)
|
|
}
|