package appui import ( "image" "image/color" "gioui.org/layout" "gioui.org/op" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" "git.julianfamily.org/keepassgo/internal/appui/actions" headerlayout "git.julianfamily.org/keepassgo/internal/appui/layout/header" "git.julianfamily.org/keepassgo/internal/vault" ) 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.Flexed(1, func(gtx layout.Context) layout.Dimensions { return u.brandMark(gtx, 196, 56) }), layout.Rigid(u.headerActions), ) }) } func (u *ui) headerActions(gtx layout.Context) layout.Dimensions { if u.shouldShowLifecycleSetup() { return layout.Dimensions{} } if u.isVaultLocked() { return layout.Dimensions{} } if u.shouldShowDesktopWorkingHeader() { return layout.Dimensions{} } spacing := gtx.Dp(unit.Dp(8)) metrics := headerlayout.ActionMetrics{Spacing: spacing} row := func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { metrics.SyncDims = u.syncButtonGroup(gtx) return 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") metrics.LockDims = btn.Layout(gtx) return metrics.LockDims }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { metrics.MainDims = u.mainMenuButtonGroup(gtx) return metrics.MainDims }), ) } rowOps := op.Record(gtx.Ops) metrics.RowDims = row(gtx) rowCall := rowOps.Stop() if u.usesCompactViewport() { metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X) } surface := headerlayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0} rowStack := op.Offset(image.Pt(metrics.RowOriginX, 0)).Push(gtx.Ops) rowCall.Add(gtx.Ops) rowStack.Pop() if u.usesCompactViewport() { if u.syncMenuOpen { u.phoneSyncMenuVisible = true u.phoneSyncMenuAnchor = metrics.SyncAnchor().Point() } if u.mainMenuOpen { u.phoneMainMenuVisible = true u.phoneMainMenuAnchor = metrics.MainAnchor().Point() } width := gtx.Constraints.Max.X return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)} } if u.syncMenuOpen { surface.Draw(gtx, metrics.SyncAnchor(), u.syncMenu) } if u.mainMenuOpen { surface.Draw(gtx, metrics.MainAnchor(), u.mainMenu) } width := metrics.RowDims.Size.X return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)} } 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") }, } rowWidth := menuActionWidth(gtx, rows) return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[0]) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[1]) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[2]) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[3]) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[4]) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[5]) }), ) }) } func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions { label := "Sync" spacing := unit.Dp(4) 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.usesCompactViewport()) }), layout.Rigid(layout.Spacer{Width: spacing}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.syncMenuToggle(gtx) }), ) } return row(gtx) } func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions { btn := material.IconButton(u.theme, &u.toggleSyncMenu, u.chevronDownIcon, "More synchronize actions") if u.syncMenuOpen { btn.Background = accentColor btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} } else { btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255} btn.Color = accentColor } btn.Size = unit.Dp(18) btn.Inset = layout.UniformInset(unit.Dp(8)) if u.usesCompactViewport() { btn.Size = unit.Dp(16) btn.Inset = layout.UniformInset(unit.Dp(7)) } return btn.Layout(gtx) } func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { model := u.buildSyncMenuModel() profiles := u.availableRemoteProfiles() credentials := u.availableRemoteCredentialEntries() if len(u.vaultRemoteProfileClicks) < len(profiles) { u.vaultRemoteProfileClicks = make([]widget.Clickable, len(profiles)) } if len(u.vaultRemoteCredentialClicks) < len(credentials) { u.vaultRemoteCredentialClicks = make([]widget.Clickable, len(credentials)) } actionRows := u.syncMenuActionRows(model) actionWidth := menuActionWidth(gtx, actionRows) return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions { rows := u.syncMenuRows(model, profiles, credentials, actionWidth) return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...) }) } func (u *ui) syncMenuActionRows(model actions.SyncMenuModel) []layout.Widget { rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") }, } if model.ShowShare { rows = append(rows, func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault") }) } if model.ShowRemoteSyncSetupShortcut() { rows = append(rows, func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel()) }) } if model.ShowDirectRemoteSyncShortcut() { rows = append(rows, func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel()) }) } if model.ShowRemoteSyncSettingsShortcut() { rows = append(rows, func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel()) }) } if model.ShowRemoveRemoteSyncShortcut() { rows = append(rows, func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel()) }) } return rows } func (u *ui) syncMenuRows(model actions.SyncMenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry, actionWidth int) []layout.FlexChild { rows := u.syncMenuPrimaryRows(model, actionWidth) rows = append(rows, u.syncMenuSavedBindingRows(model, profiles, credentials)...) if model.ShowSaveCurrentBinding { rows = append(rows, u.syncMenuSaveBindingRows(model)...) } return rows } func (u *ui) syncMenuPrimaryRows(model actions.SyncMenuModel, actionWidth int) []layout.FlexChild { rows := []layout.FlexChild{ layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(11), "Need another source or direction?") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), } if model.ShowShare { rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault") }) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), ) })) } rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openAdvancedSync, "Open Advanced Sync")) if model.ShowRemoteSyncSetupShortcut() { rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel())) } if model.ShowDirectRemoteSyncShortcut() { rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel())) } if model.ShowRemoteSyncSettingsShortcut() { rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel())) } if model.ShowRemoveRemoteSyncShortcut() { rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) rows = append(rows, u.syncMenuActionRow(actionWidth, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel())) } return rows } func (u *ui) syncMenuActionRow(actionWidth int, click *widget.Clickable, label string) layout.FlexChild { return layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, click, label) }) }) } func (u *ui) syncMenuSavedBindingRows(model actions.SyncMenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild { if !u.hasOpenVault() || len(profiles) == 0 || len(credentials) == 0 { return nil } rows := []layout.FlexChild{ layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(11), model.SavedBindingHeading()) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), } if !model.ShowSelectors { rows = append(rows, layout.Rigid(u.syncMenuSavedBindingSummary(model))) } else { rows = append(rows, u.syncMenuSelectorRows(model, profiles, credentials)...) } if _, ok := u.selectedVaultRemoteProfile(); ok { if _, ok := u.selectedVaultRemoteCredentialEntry(); ok { rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.openSelectedVaultRemoteButtonLabel()) }), ) } } return rows } func (u *ui) syncMenuSavedBindingSummary(model actions.SyncMenuModel) layout.Widget { return func(gtx layout.Context) layout.Dimensions { summary := model.SavedBindingSummary if !summary.OK { return layout.Dimensions{} } return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 242, G: 245, B: 240, A: 255}), func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(13), summary.ProfileLabel) lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "Credential: "+summary.CredentialLabel) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), summary.SyncLabel) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) }) }) } } func (u *ui) syncMenuSaveBindingRows(model actions.SyncMenuModel) []layout.FlexChild { return []layout.FlexChild{ layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(11), model.SaveCurrentRemoteBindingHeading()) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.saveCurrentRemoteBinding, model.SaveCurrentRemoteBindingButtonLabel()) }), } } func (u *ui) syncMenuSelectorRows(_ actions.SyncMenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild { rows := make([]layout.FlexChild, 0, len(profiles)+len(credentials)+4) for i, profile := range profiles { i := i profile := profile rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { selected := u.selectedVaultRemoteProfileID == profile.ID return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions { return u.vaultRemoteProfileClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(13), profile.Name) lbl.Color = accentColor return lbl.Layout(gtx) }) }) }) })) } rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) for i, entry := range credentials { i := i entry := entry rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { selected := u.selectedVaultRemoteCredentialEntryID == entry.ID label := entry.Title if entry.Username != "" { label += " ยท " + entry.Username } return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions { return u.vaultRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(13), label) lbl.Color = accentColor return lbl.Layout(gtx) }) }) }) })) } return rows } func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions { measureGTX := gtx measureGTX.Constraints.Min = image.Point{} measureGTX.Constraints.Max.X = gtx.Constraints.Max.X macro := op.Record(gtx.Ops) contentDims := w(measureGTX) _ = macro.Stop() width := contentDims.Size.X + gtx.Dp(unit.Dp(20)) maxWidth := gtx.Constraints.Max.X if maxWidth > 0 && width > maxWidth { width = maxWidth } if width > 0 { gtx.Constraints.Min.X = width gtx.Constraints.Max.X = width } return compactCard(gtx, w) } func (u *ui) topRightActionOrder() []string { if u.isVaultLocked() { return nil } return []string{"Sync", "Lock", "Menu"} } func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions { button := func(gtx layout.Context) layout.Dimensions { icon := u.menuIcon if icon == nil { icon = u.settingsIcon } btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu") if u.mainMenuOpen { btn.Background = accentColor btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} } else { btn.Background = selectedColor btn.Color = accentColor } btn.Size = unit.Dp(18) btn.Inset = layout.UniformInset(unit.Dp(8)) return btn.Layout(gtx) } return button(gtx) } func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions { if !u.usesCompactViewport() { return layout.Dimensions{} } if !u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone() { 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() { surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu) } if u.mainMenuVisibleOnPhone() { surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu) } return layout.Dimensions{Size: gtx.Constraints.Max} } 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 menuActionWidth(gtx layout.Context, rows []layout.Widget) int { width := 0 for _, row := range rows { measureGTX := gtx measureGTX.Constraints.Min = image.Point{} macro := op.Record(gtx.Ops) dims := row(measureGTX) _ = macro.Stop() if dims.Size.X > width { width = dims.Size.X } } return width } func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions { if width <= 0 { return child(gtx) } gtx.Constraints.Min.X = width gtx.Constraints.Max.X = width return layout.E.Layout(gtx, child) }