package appui import ( "fmt" "image" "image/color" "os" "path/filepath" "runtime" "slices" "strings" "time" "gioui.org/gesture" "gioui.org/io/key" "gioui.org/io/pointer" "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" "gioui.org/x/explorer" "git.julianfamily.org/keepassgo/internal/api" "git.julianfamily.org/keepassgo/internal/apiapproval" "git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/appstate" apiui "git.julianfamily.org/keepassgo/internal/appui/api" detailmodel "git.julianfamily.org/keepassgo/internal/appui/detail" detaillayout "git.julianfamily.org/keepassgo/internal/appui/detail/layout" lifecyclemodel "git.julianfamily.org/keepassgo/internal/appui/lifecycle" listmodel "git.julianfamily.org/keepassgo/internal/appui/list" listlayout "git.julianfamily.org/keepassgo/internal/appui/list/layout" "git.julianfamily.org/keepassgo/internal/appui/platform" syncmodel "git.julianfamily.org/keepassgo/internal/appui/sync" keepassassets "git.julianfamily.org/keepassgo/internal/assets" "git.julianfamily.org/keepassgo/internal/clipboard" "git.julianfamily.org/keepassgo/internal/passwords" "git.julianfamily.org/keepassgo/internal/vault" "golang.org/x/exp/shiny/materialdesign/icons" ) var appVersion = "dev" func currentAppVersion() string { if strings.TrimSpace(appVersion) == "" { return "dev" } return strings.TrimSpace(appVersion) } type entry = vault.Entry const ( productName = "KeePassGO" ) const ( maxAttachmentBytes = 10 << 20 statusBannerDuration = 2600 * time.Millisecond statusBannerLong = 6 * time.Second autofillStatusTTL = 12 * time.Second ) type autofillNoticeMode string const ( autofillNoticeAll autofillNoticeMode = "all" autofillNoticeApprovals autofillNoticeMode = "approvals" autofillNoticeSuppressed autofillNoticeMode = "suppressed" ) type bannerKind string const ( bannerNone bannerKind = "" bannerLoading bannerKind = "loading" bannerError bannerKind = "error" bannerStatus bannerKind = "status" ) type uiBanner struct { Kind bannerKind Message string Detail string Dismissable bool } type autofillStatusKind string const ( autofillStatusNone autofillStatusKind = "" autofillStatusFound autofillStatusKind = "found" autofillStatusAmbiguous autofillStatusKind = "ambiguous" autofillStatusBlocked autofillStatusKind = "blocked" autofillStatusAwaitingApproval autofillStatusKind = "awaiting_approval" ) type uiAutofillStatus struct { Kind autofillStatusKind Title string Message string Detail string } type uiSurface struct { Title string Message string Locked bool } type lifecycleOpenIntent = lifecyclemodel.OpenIntent const ( lifecycleOpenIntentNone = lifecyclemodel.OpenIntentNone lifecycleOpenIntentRemoteSyncSetup = lifecyclemodel.OpenIntentRemoteSyncSetup lifecycleOpenIntentRemoteSyncSettings = lifecyclemodel.OpenIntentRemoteSyncSettings ) type emptyState = detailmodel.EmptyState type vaultSummary = detailmodel.VaultSummary type sessionStatus interface { HasVault() bool IsLocked() bool IsRemote() bool } type attachmentItem = detailmodel.AttachmentItem type statePaths struct { DefaultSaveAsPath string RecentVaultsPath string RecentRemotesPath string SettingsPath string UIPreferencesPath string AutofillCachePath string PendingSharedVaultPath string PendingSharedVaultNamePath string PendingSharedLookupPath string } type recentVaultRecord struct { Path string `json:"path"` LastGroup []string `json:"lastGroup,omitempty"` UsedAt string `json:"usedAt,omitempty"` } type recentRemoteRecord struct { BaseURL string `json:"baseUrl"` Path string `json:"path"` LocalVaultPath string `json:"localVaultPath,omitempty"` RemoteProfileID string `json:"remoteProfileId,omitempty"` CredentialEntryID string `json:"credentialEntryId,omitempty"` SyncMode string `json:"syncMode,omitempty"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` LastGroup []string `json:"lastGroup,omitempty"` UsedAt string `json:"usedAt,omitempty"` NeedsMigration bool `json:"-"` } type uiPreferences struct { GroupControlsHidden bool `json:"groupControlsHidden"` LifecycleAdvancedHidden bool `json:"lifecycleAdvancedHidden"` HistoryHidden bool `json:"historyHidden"` DenseLayout bool `json:"denseLayout"` StatusBannerMillis int `json:"statusBannerMillis,omitempty"` AutofillNoticeMode string `json:"autofillNoticeMode,omitempty"` DisplayDensity string `json:"displayDensity,omitempty"` Contrast string `json:"contrast,omitempty"` ReducedMotion bool `json:"reducedMotion,omitempty"` KeyboardFocus string `json:"keyboardFocus,omitempty"` AutofillPrivacy autofillPrivacySettings `json:"autofillPrivacy,omitempty"` } type autofillPrivacySettings struct { FirstFillApprovalMode string `json:"firstFillApprovalMode,omitempty"` BrowserAllowlist []string `json:"browserAllowlist,omitempty"` AppAllowlist []string `json:"appAllowlist,omitempty"` PackageRules []string `json:"packageRules,omitempty"` } type autofillFirstFillApprovalMode string const ( autofillFirstFillApprovalAsk autofillFirstFillApprovalMode = "ask" autofillFirstFillApprovalAllow autofillFirstFillApprovalMode = "allow" autofillFirstFillApprovalBlock autofillFirstFillApprovalMode = "block" ) type entriesSectionState = listmodel.EntriesSectionState type syncSourceMode = syncmodel.SourceMode const ( syncSourceLocal = syncmodel.SourceLocal syncSourceRemote = syncmodel.SourceRemote ) type syncDirection = syncmodel.Direction const ( syncDirectionPull = syncmodel.DirectionPull syncDirectionPush = syncmodel.DirectionPush ) type syncDialogPurpose = syncmodel.DialogPurpose const ( syncDialogPurposeAdvanced = syncmodel.DialogPurposeAdvanced syncDialogPurposeRemoteSetup = syncmodel.DialogPurposeRemoteSetup ) type ui struct { mode string theme *material.Theme fileExplorer *explorer.Explorer logoHorizontal paint.ImageOp splashSquare paint.ImageOp search widget.Editor vaultPath widget.Editor saveAsPath widget.Editor remoteBaseURL widget.Editor remotePath widget.Editor remoteUsername widget.Editor remotePassword widget.Editor masterPassword widget.Editor keyFilePath widget.Editor apiTokenName widget.Editor apiTokenClientName widget.Editor apiTokenExpiresAt widget.Editor apiPolicyOperation widget.Editor apiPolicyPath widget.Editor apiPolicyEntryID widget.Editor securityCipher widget.Editor securityKDF widget.Editor entryID widget.Editor entryTitle widget.Editor entryUsername widget.Editor entryPassword widget.Editor entryURL widget.Editor entryNotes widget.Editor entryTags widget.Editor entryPath widget.Editor entryFields widget.Editor customFieldKeys []widget.Editor customFieldValues []widget.Editor historyIndex widget.Editor groupName widget.Editor groupParentPath widget.Editor passwordProfile widget.Editor attachmentName widget.Editor attachmentPath widget.Editor exportAttachmentPath widget.Editor autofillBrowserAllowlist widget.Editor autofillAppAllowlist widget.Editor autofillPackageRules widget.Editor list widget.List groupList widget.List detailList widget.List apiPolicyList widget.List lifecycleList widget.List syncDialogList widget.List phonePanelList widget.List securityDialogList widget.List remotePrefsDialogList widget.List recentVaultListState widget.List recentRemoteListState widget.List copyUser widget.Clickable copyPass widget.Clickable copyURL widget.Clickable lockVault widget.Clickable unlockVault widget.Clickable createVault widget.Clickable openVault widget.Clickable lifecycleRemoteSyncAction widget.Clickable saveVault widget.Clickable saveAsVault widget.Clickable openRemote widget.Clickable changeMasterKey widget.Clickable synchronizeVault widget.Clickable toggleSyncMenu widget.Clickable toggleMainMenu widget.Clickable openAdvancedSync widget.Clickable useSavedAdvancedSyncRemote widget.Clickable openSelectedVaultRemote widget.Clickable saveCurrentRemoteBinding widget.Clickable removeSelectedRemoteBinding widget.Clickable openSecuritySettings widget.Clickable openRemotePrefsHelp widget.Clickable closeAdvancedSync widget.Clickable closeSecuritySettings widget.Clickable closeRemotePrefsHelp widget.Clickable runAdvancedSync widget.Clickable saveSecuritySettings widget.Clickable settingsDensityDense widget.Clickable settingsDensityComfortable widget.Clickable settingsContrastStandard widget.Clickable settingsContrastHigh widget.Clickable settingsReducedMotionOff widget.Clickable settingsReducedMotionOn widget.Clickable settingsKeyboardFocusStandard widget.Clickable settingsKeyboardFocusProminent widget.Clickable showSettingsSyncLocal widget.Clickable showSettingsSyncRemote widget.Clickable showSettingsSyncPull widget.Clickable showSettingsSyncPush widget.Clickable editEntry widget.Clickable cancelEdit widget.Clickable pickVaultPath widget.Clickable importSharedVault widget.Clickable shareCurrentVault widget.Clickable pickKeyFile widget.Clickable pickSyncLocalPath widget.Clickable clearVaultSelection widget.Clickable clearRemoteSelection widget.Clickable dismissBanner widget.Clickable addEntry widget.Clickable saveEntry widget.Clickable duplicateEntry widget.Clickable deleteEntry widget.Clickable restoreEntry widget.Clickable saveTemplate widget.Clickable deleteTemplate widget.Clickable instantiateTemplate widget.Clickable addAttachment widget.Clickable replaceAttachment widget.Clickable removeAttachment widget.Clickable exportAttachment widget.Clickable restoreHistory widget.Clickable generatePassword widget.Clickable createGroup widget.Clickable moveGroup widget.Clickable renameGroup widget.Clickable deleteGroup widget.Clickable confirmDeleteGroup widget.Clickable cancelDeleteGroup widget.Clickable addCustomField widget.Clickable toggleGroupControls widget.Clickable toggleLifecycleAdvanced widget.Clickable toggleHistory widget.Clickable togglePasswordInline widget.Clickable toggleSyncPassword widget.Clickable setStatusBannerShort widget.Clickable setStatusBannerStandard widget.Clickable setStatusBannerLong widget.Clickable showAllAutofillNotices widget.Clickable showApprovalAutofillOnly widget.Clickable hideAutofillNotices widget.Clickable showEntries widget.Clickable showTemplates widget.Clickable showRecycle widget.Clickable showAPITokens widget.Clickable showAPIAudit widget.Clickable showAbout widget.Clickable showLocalLifecycle widget.Clickable showRemoteLifecycle widget.Clickable showSyncLocal widget.Clickable showSyncRemote widget.Clickable showSyncPull widget.Clickable showSyncPush widget.Clickable showAutofillApprovalAsk widget.Clickable showAutofillApprovalAllow widget.Clickable showAutofillApprovalBlock widget.Clickable allowApprovalOnce widget.Clickable allowApprovalPermanent widget.Clickable denyApprovalOnce widget.Clickable denyApprovalPermanent widget.Clickable cancelApproval widget.Clickable cancelLifecycleProgress widget.Clickable retryLifecycleOpen widget.Clickable syncSetupAutomatic widget.Bool apiPolicyAllow widget.Bool apiPolicyGroupScopeW widget.Bool apiTokenDisabled widget.Bool settingsGroupControls widget.Bool settingsLifecycleAdvanced widget.Bool settingsHistory widget.Bool settingsDenseLayout widget.Bool settingsDebugHeaderBounds widget.Bool settingsAutoSaveRemote widget.Bool entryClicks []widget.Clickable apiTokenClicks []widget.Clickable apiPolicyEdits []widget.Clickable apiPolicyRemoves []widget.Clickable apiAuditClicks []widget.Clickable apiAuditTokenFilters []widget.Clickable apiAuditDecisionFilters []widget.Clickable apiAuditOperationFilters []widget.Clickable clearAPIAuditFilters widget.Clickable historyClicks []widget.Clickable attachmentClicks []widget.Clickable breadcrumbs []widget.Clickable groupClicks []widget.Clickable recentVaultClicks []widget.Clickable recentRemoteClicks []widget.Clickable vaultRemoteProfileClicks []widget.Clickable vaultRemoteCredentialClicks []widget.Clickable syncRemoteCredentialClicks []widget.Clickable removeCustomFields []widget.Clickable state appstate.State visible []entry currentPath []string syncedPath []string selectedHistoryIndex int showPassword bool generatedPasswordDraft bool togglePassword widget.Clickable copyAPITokenSecret widget.Clickable issueAPIToken widget.Clickable saveAPIToken widget.Clickable rotateAPIToken widget.Clickable disableAPIToken widget.Clickable revokeAPIToken widget.Clickable deleteAPIToken widget.Clickable useCurrentGroupForPolicy widget.Clickable useSelectedEntryForPolicy widget.Clickable clearAPIPolicyTarget widget.Clickable addAPIPolicyRule widget.Clickable saveAPIPolicyRule widget.Clickable cancelAPIPolicyEdit widget.Clickable phoneSplit widget.Float splitDrag gesture.Drag splitBase float32 splitStartY float32 phoneSpan int compactViewport bool viewportMeasured bool phoneGroupBrowserExpanded bool eyeIcon *widget.Icon eyeOffIcon *widget.Icon copyIcon *widget.Icon expandMoreIcon *widget.Icon expandLessIcon *widget.Icon chevronRightIcon *widget.Icon chevronDownIcon *widget.Icon settingsIcon *widget.Icon menuIcon *widget.Icon clipboardWriter clipboard.Writer vaultSharer platform.VaultSharer loadingMessage string loadingActionLabel string lifecycleMode string syncSourceMode syncSourceMode syncDirection syncDirection syncLocalImportName string syncLocalImportContent []byte syncLocalPath widget.Editor syncRemoteBaseURL widget.Editor syncRemotePath widget.Editor syncRemoteUsername widget.Editor syncRemotePassword widget.Editor selectedSyncRemoteCredentialEntryID string syncDialogPurpose syncDialogPurpose syncDialogOpen bool syncMenuOpen bool mainMenuOpen bool selectedRemoteConnection bool selectedVaultRemoteProfileID string selectedVaultRemoteCredentialEntryID string selectedVaultRemoteSyncMode appstate.SyncMode securityDialogOpen bool remotePrefsDialogOpen bool showSyncPassword bool keyboardFocus focusID defaultSaveAsPath string recentVaultsPath string settingsPath string uiPreferencesPath string recentRemotesPath string autofillCachePath string pendingSharedVaultPath string pendingSharedVaultNamePath string pendingSharedLookupPath string pendingSharedLookupQuery string editingEntry bool syncDefaultSourceMode syncSourceMode syncDefaultDirection syncDirection autoSaveRemote bool groupControlsHidden bool lifecycleAdvancedHidden bool historyHidden bool denseLayout bool statusBannerTTL time.Duration autofillNoticePreference autofillNoticeMode autofillFirstFillApprovalMode autofillFirstFillApprovalMode accessibilityPrefs accessibilityPreferences debugLogHeaderBounds bool settingsDraft settingsDraft recentVaults []string recentRemotes []recentRemoteRecord recentVaultGroups map[string][]string recentVaultUsedAt map[string]time.Time entriesState entriesSectionState deleteGroupPath []string apiPolicyGroupScope bool selectedAPIPolicyIndex int apiTokenSecret string phoneSyncMenuOrigin image.Point phoneMainMenuOrigin image.Point phoneSyncMenuSize image.Point phoneMainMenuSize image.Point phoneSyncMenuCall op.CallOp phoneMainMenuCall op.CallOp phoneSyncMenuVisible bool phoneMainMenuVisible bool selectedAuditIndex int statusExpiresAt time.Time now func() time.Time apiHost *api.Host auditLog *apiaudit.Log grpcAddress string backgroundResults chan backgroundActionResult backgroundActionSerial int activeBackgroundAction int lastLifecycleAction string pendingLifecycleOpenIntent lifecycleOpenIntent requestMasterPassFocus bool lastHeaderBoundsLog string frameInsetPx int invalidate func() } type backgroundActionResult struct { label string apply func() error err error id int } var ( bgColor = color.NRGBA{R: 242, G: 239, B: 233, A: 255} panelColor = color.NRGBA{R: 250, G: 248, B: 244, A: 255} accentColor = color.NRGBA{R: 28, G: 83, B: 63, A: 255} mutedColor = color.NRGBA{R: 95, G: 93, B: 88, A: 255} selectedColor = color.NRGBA{R: 221, G: 233, B: 226, A: 255} selectedEdge = color.NRGBA{R: 73, G: 123, B: 100, A: 255} ) const ( errVaultPathRequired = "vault path is required" errSaveAsPathRequired = "save-as path is required" ) func newUIWithModel(mode string, model vault.Model) *ui { return newUIWithState(mode, &uiSession{model: model}, defaultStatePaths("")) } func newUIWithSession(mode string, sess appstate.CurrentSession, paths ...statePaths) *ui { selected := defaultStatePaths("") if len(paths) > 0 { selected = paths[0] } return newUIWithState(mode, sess, selected) } func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) *ui { th := material.NewTheme() th.Palette.Bg = bgColor th.Palette.Fg = color.NRGBA{R: 31, G: 29, B: 27, A: 255} th.Palette.ContrastBg = accentColor th.Palette.ContrastFg = color.NRGBA{R: 255, G: 252, B: 247, A: 255} u := &ui{ mode: mode, theme: th, logoHorizontal: paint.NewImageOp(keepassassets.MustPNG("keepassgo-logo-horizontal.png")), splashSquare: paint.NewImageOp(keepassassets.MustPNG("keepassgo-splash-square.png")), search: widget.Editor{ SingleLine: true, Submit: false, }, vaultPath: widget.Editor{SingleLine: true, Submit: false}, saveAsPath: widget.Editor{SingleLine: true, Submit: false}, remoteBaseURL: widget.Editor{SingleLine: true, Submit: false}, remotePath: widget.Editor{SingleLine: true, Submit: false}, remoteUsername: widget.Editor{SingleLine: true, Submit: false}, remotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•', InputHint: key.HintPassword}, syncLocalPath: widget.Editor{SingleLine: true, Submit: false}, syncRemoteBaseURL: widget.Editor{SingleLine: true, Submit: false}, syncRemotePath: widget.Editor{SingleLine: true, Submit: false}, syncRemoteUsername: widget.Editor{SingleLine: true, Submit: false}, syncRemotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•', InputHint: key.HintPassword}, masterPassword: widget.Editor{SingleLine: true, Submit: false, InputHint: key.HintPassword}, keyFilePath: widget.Editor{SingleLine: true, Submit: false}, apiTokenName: widget.Editor{SingleLine: true, Submit: false}, apiTokenClientName: widget.Editor{SingleLine: true, Submit: false}, apiTokenExpiresAt: widget.Editor{SingleLine: true, Submit: false}, apiPolicyOperation: widget.Editor{SingleLine: true, Submit: false}, apiPolicyPath: widget.Editor{SingleLine: true, Submit: false}, apiPolicyEntryID: widget.Editor{SingleLine: true, Submit: false}, securityCipher: widget.Editor{SingleLine: true, Submit: false}, securityKDF: widget.Editor{SingleLine: true, Submit: false}, entryID: widget.Editor{SingleLine: true, Submit: false}, entryTitle: widget.Editor{SingleLine: true, Submit: false}, entryUsername: widget.Editor{SingleLine: true, Submit: false}, entryPassword: widget.Editor{SingleLine: true, Submit: false}, entryURL: widget.Editor{SingleLine: true, Submit: false}, entryNotes: widget.Editor{SingleLine: false, Submit: false}, entryTags: widget.Editor{SingleLine: true, Submit: false}, entryPath: widget.Editor{SingleLine: true, Submit: false}, entryFields: widget.Editor{SingleLine: false, Submit: false}, historyIndex: widget.Editor{SingleLine: true, Submit: false}, groupName: widget.Editor{SingleLine: true, Submit: false}, groupParentPath: widget.Editor{SingleLine: true, Submit: false}, passwordProfile: widget.Editor{SingleLine: true, Submit: false}, attachmentName: widget.Editor{SingleLine: true, Submit: false}, attachmentPath: widget.Editor{SingleLine: true, Submit: false}, exportAttachmentPath: widget.Editor{SingleLine: true, Submit: false}, autofillBrowserAllowlist: widget.Editor{SingleLine: false, Submit: false}, autofillAppAllowlist: widget.Editor{SingleLine: false, Submit: false}, autofillPackageRules: widget.Editor{SingleLine: false, Submit: false}, list: widget.List{ List: layout.List{Axis: layout.Vertical}, }, groupList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, detailList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, apiPolicyList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, lifecycleList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, syncDialogList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, phonePanelList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, securityDialogList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, remotePrefsDialogList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, recentVaultListState: widget.List{ List: layout.List{Axis: layout.Vertical}, }, recentRemoteListState: widget.List{ List: layout.List{Axis: layout.Vertical}, }, state: appstate.State{}, selectedHistoryIndex: -1, selectedAuditIndex: -1, lifecycleMode: "local", defaultSaveAsPath: paths.DefaultSaveAsPath, recentVaultsPath: paths.RecentVaultsPath, settingsPath: paths.SettingsPath, uiPreferencesPath: paths.UIPreferencesPath, recentRemotesPath: paths.RecentRemotesPath, autofillCachePath: paths.AutofillCachePath, pendingSharedVaultPath: paths.PendingSharedVaultPath, pendingSharedVaultNamePath: paths.PendingSharedVaultNamePath, pendingSharedLookupPath: paths.PendingSharedLookupPath, recentVaultGroups: map[string][]string{}, recentVaultUsedAt: map[string]time.Time{}, lifecycleAdvancedHidden: true, historyHidden: true, statusBannerTTL: statusBannerDuration, accessibilityPrefs: defaultAccessibilityPreferences(), autofillFirstFillApprovalMode: autofillFirstFillApprovalAsk, now: time.Now, syncSourceMode: syncSourceLocal, syncDirection: syncDirectionPull, syncDefaultSourceMode: syncSourceLocal, syncDefaultDirection: syncDirectionPull, autoSaveRemote: false, apiPolicyGroupScope: true, autofillNoticePreference: autofillNoticeAll, vaultSharer: platform.NewVaultSharer(runtime.GOOS), backgroundResults: make(chan backgroundActionResult, 8), phoneGroupBrowserExpanded: true, selectedAPIPolicyIndex: -1, } if mode == "phone" { u.groupControlsHidden = true } u.apiPolicyAllow.Value = true u.apiPolicyGroupScopeW.Value = true u.state.Session = sess u.state.AutoSaveRemote = u.autoSaveRemote u.phoneSplit.Value = 0.46 u.eyeIcon, _ = widget.NewIcon(icons.ActionVisibility) u.eyeOffIcon, _ = widget.NewIcon(icons.ActionVisibilityOff) u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy) u.expandMoreIcon, _ = widget.NewIcon(icons.NavigationExpandMore) u.expandLessIcon, _ = widget.NewIcon(icons.NavigationExpandLess) u.chevronRightIcon, _ = widget.NewIcon(icons.NavigationChevronRight) u.chevronDownIcon, _ = widget.NewIcon(icons.NavigationArrowDropDown) u.settingsIcon, _ = widget.NewIcon(icons.ActionSettings) u.menuIcon, _ = widget.NewIcon(icons.NavigationMenu) u.passwordProfile.SetText("strong") u.securityCipher.SetText(vault.CipherChaCha20) u.securityKDF.SetText(vault.KDFArgon2) u.keyboardFocus = focusSearch u.setCustomFieldRows(nil) u.loadRecentVaults() u.loadRecentRemotes() if u.hasLegacyRecentRemoteCredentialMigration() { u.showStatusMessage("Some saved remote sign-ins came from an older KeePassGO build. Reopen those remotes and save them in the vault to migrate them.") } u.consumePendingSharedVaultImport() u.consumePendingSharedLookup() u.restoreStartupLifecycleTarget() u.requestMasterPassFocus = u.hasSelectedLifecycleTarget() u.loadUIPreferences() u.loadSettings() if u.debugLogHeaderBounds { platform.LogInfo("KeePassGO", "keepassgo header-bounds logging enabled") } u.loadSettingsFormFromPreferences() u.loadSettingsDraft() u.requestMasterPassFocus = u.hasSelectedLifecycleTarget() u.filter() u.syncAutofillCache() return u } func (u *ui) filter() { u.state.SetSearchQuery(u.search.Text()) visible, err := u.state.VisibleEntries() if err != nil { u.visible = nil return } u.visible = visible u.ensureEntryClicks() } func (u *ui) ensureEntryClicks() { if len(u.entryClicks) >= len(u.visible) { return } next := make([]widget.Clickable, len(u.visible)) copy(next, u.entryClicks) u.entryClicks = next } func (u *ui) visibleEntrySnapshot() ([]entry, []*widget.Clickable) { visible := append([]entry(nil), u.visible...) u.ensureEntryClicks() clicks := make([]*widget.Clickable, len(visible)) for i := range visible { clicks[i] = &u.entryClicks[i] } return visible, clicks } func (u *ui) searchPlaceholder() string { switch u.state.Section { case appstate.SectionAPITokens: return "Search API tokens" case appstate.SectionAPIAudit: return "Search audit log" case appstate.SectionRecycleBin: return "Search recycle bin" case appstate.SectionAbout: return "Search disabled on About" default: return "Search vault" } } func defaultStatePaths(stateDir string) statePaths { baseDir := strings.TrimSpace(stateDir) if baseDir == "" { baseDir = strings.TrimSpace(os.Getenv("KEEPASSGO_STATE_DIR")) } if baseDir == "" { configDir, err := os.UserConfigDir() if err != nil || strings.TrimSpace(configDir) == "" { configDir = os.TempDir() } baseDir = filepath.Join(configDir, "keepassgo") } return statePaths{ DefaultSaveAsPath: filepath.Join(baseDir, "vault.kdbx"), RecentVaultsPath: filepath.Join(baseDir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(baseDir, "recent-remotes.json"), SettingsPath: filepath.Join(baseDir, "settings.json"), UIPreferencesPath: filepath.Join(baseDir, "ui-prefs.json"), AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"), PendingSharedVaultPath: filepath.Join(baseDir, "pending-shared-vault.kdbx"), PendingSharedVaultNamePath: filepath.Join(baseDir, "pending-shared-vault-name.txt"), PendingSharedLookupPath: filepath.Join(baseDir, "pending-shared-lookup.txt"), } } func resolveFlagOrEnv(flagValue, envName, fallback string) string { if value := strings.TrimSpace(flagValue); value != "" { return value } if value := strings.TrimSpace(os.Getenv(envName)); value != "" { return value } return fallback } func defaultModeForRuntime(goos string) string { if strings.EqualFold(strings.TrimSpace(goos), "android") { return "phone" } return "desktop" } func shouldUsePreviewWindowSize(mode, goos string) bool { return !strings.EqualFold(strings.TrimSpace(goos), "android") } func supportsDesktopFilePicker(goos string) bool { return !strings.EqualFold(strings.TrimSpace(goos), "android") } func supportsSharedVaultImport(goos string) bool { return strings.EqualFold(strings.TrimSpace(goos), "android") } func supportsVaultShare(goos string) bool { return strings.EqualFold(strings.TrimSpace(goos), "android") } func (u *ui) selectedAttachmentItems() []attachmentItem { item, ok := u.selectedEntry() if !ok || len(item.Attachments) == 0 { return nil } items := make([]attachmentItem, 0, len(item.Attachments)) for name, content := range item.Attachments { items = append(items, attachmentItem{Name: name, Size: len(content)}) } slices.SortFunc(items, func(a, b attachmentItem) int { switch { case a.Name < b.Name: return -1 case a.Name > b.Name: return 1 default: return 0 } }) if len(u.attachmentClicks) < len(items) { u.attachmentClicks = make([]widget.Clickable, len(items)) } return items } func (u *ui) selectedAttachmentNames() []string { items := u.selectedAttachmentItems() names := make([]string, 0, len(items)) for _, item := range items { names = append(names, item.Name) } return names } func (u *ui) showEntriesSection() { u.resetPasswordPeek() u.state.ShowSection(appstate.SectionEntries) u.mainMenuOpen = false u.restoreEntriesSectionState() u.filter() } func (u *ui) showTemplatesSection() { u.resetPasswordPeek() u.rememberEntriesSectionState() u.state.ShowSection(appstate.SectionTemplates) u.mainMenuOpen = false u.filter() } func (u *ui) showRecycleBinSection() { u.resetPasswordPeek() u.rememberEntriesSectionState() u.state.ShowSection(appstate.SectionRecycleBin) u.mainMenuOpen = false u.filter() } func (u *ui) showAPITokensSection() { u.resetPasswordPeek() u.rememberEntriesSectionState() u.state.ShowSection(appstate.SectionAPITokens) u.mainMenuOpen = false u.loadSelectedAPITokenIntoEditor() u.filter() } func (u *ui) showAPIAuditSection() { u.resetPasswordPeek() u.rememberEntriesSectionState() u.state.ShowSection(appstate.SectionAPIAudit) u.mainMenuOpen = false u.selectedAuditIndex = -1 u.filter() } func (u *ui) showAboutSection() { u.resetPasswordPeek() u.rememberEntriesSectionState() u.state.ShowSection(appstate.SectionAbout) u.mainMenuOpen = false u.filter() } func (u *ui) returnToMainEntries() { u.clearDeleteGroupConfirmation() u.showEntriesSection() } func (u *ui) handlePhoneBack() bool { if u.mode != "phone" { return false } switch { case u.securityDialogOpen: u.securityDialogOpen = false case u.remotePrefsDialogOpen: u.remotePrefsDialogOpen = false case u.syncDialogOpen: u.syncDialogOpen = false case u.mainMenuOpen: u.mainMenuOpen = false case u.state.Section != appstate.SectionEntries: u.returnToMainEntries() default: return false } return true } func (u *ui) resetPasswordPeek() { u.showPassword = false } func (u *ui) childGroups() []string { u.state.SetSearchQuery(u.search.Text()) groups, err := u.state.ChildGroups() if err != nil { return nil } return groups } func (u *ui) passwordProfileOptionsText() string { return "Available profiles: " + strings.Join(passwords.DefaultProfileNames(), ", ") } func (u *ui) filteredTitles() []string { titles := make([]string, 0, len(u.visible)) for _, item := range u.visible { titles = append(titles, item.Title) } return titles } func (u *ui) visiblePathContexts() []string { paths := make([]string, 0, len(u.visible)) for _, item := range u.visible { paths = append(paths, u.state.SearchPathContext(item)) } return paths } func (u *ui) selectedEntry() (entry, bool) { for _, item := range u.visible { if item.ID == u.state.SelectedEntryID { return item, true } } model, err := u.state.Session.Current() if err != nil { return entry{}, false } for _, item := range model.Entries { if item.ID == u.state.SelectedEntryID { return item, true } } for _, item := range model.Templates { if item.ID == u.state.SelectedEntryID { return item, true } } for _, item := range model.RecycleBin { if item.ID == u.state.SelectedEntryID { return item, true } } return entry{}, false } func (u *ui) ensureHistoryClickables() { history := u.visibleHistory() if len(u.historyClicks) < len(history) { u.historyClicks = make([]widget.Clickable, len(history)) } } func (u *ui) currentMasterKey() (vault.MasterKey, error) { password := u.masterPassword.Text() path := strings.TrimSpace(u.keyFilePath.Text()) if password == "" && path == "" { return vault.MasterKey{}, fmt.Errorf("master password or key file is required") } if path == "" { return vault.MasterKey{Password: password}, nil } content, err := os.ReadFile(path) if err != nil { return vault.MasterKey{}, fmt.Errorf("read key file: %w", err) } if len(content) == 0 { return vault.MasterKey{}, fmt.Errorf("key file is empty") } return vault.MasterKey{ Password: password, KeyFileData: content, }, nil } func (u *ui) setMasterKeyMode(vault.MasterKeyMode) {} func (u *ui) syncHostedAPI() { if u.apiHost == nil { return } if err := u.apiHost.SyncFromLifecycle(); err != nil { u.state.ErrorMessage = fmt.Sprintf("sync gRPC API: %v", err) } } func (u *ui) securityDialog(gtx layout.Context) layout.Dimensions { return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op()) return layout.Dimensions{Size: gtx.Constraints.Max} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { width := gtx.Dp(unit.Dp(620)) if width > gtx.Constraints.Max.X { width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24)) } if width < 1 { width = gtx.Constraints.Max.X } gtx.Constraints.Min.X = width gtx.Constraints.Max.X = width return card(gtx, u.securityDialogContent) }) }), ) } func (u *ui) remotePrefsDialog(gtx layout.Context) layout.Dimensions { return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op()) return layout.Dimensions{Size: gtx.Constraints.Max} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { width := gtx.Dp(unit.Dp(660)) if width > gtx.Constraints.Max.X { width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24)) } if width < 1 { width = gtx.Constraints.Max.X } gtx.Constraints.Min.X = width gtx.Constraints.Max.X = width return card(gtx, u.remotePrefsDialogContent) }) }), ) } func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions { rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(20), "Settings") lbl.Color = accentColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(6)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "ACCESSIBILITY") lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(4)}.Layout, func(gtx layout.Context) layout.Dimensions { return u.settingsPreferenceCard(gtx, "Display Density", "Adjust editor height and control spacing for denser or roomier forms.", func(gtx layout.Context) layout.Dimensions { return u.settingsChoiceRow( gtx, choiceSpec{Click: &u.settingsDensityDense, Label: "Dense", Active: u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense}, choiceSpec{Click: &u.settingsDensityComfortable, Label: "Comfortable", Active: u.settingsDraft.Accessibility.DisplayDensity == displayDensityComfortable}, ) }) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { return u.settingsPreferenceCard(gtx, "Contrast", "Increase focus and selection contrast without changing unrelated vault behavior.", func(gtx layout.Context) layout.Dimensions { return u.settingsChoiceRow( gtx, choiceSpec{Click: &u.settingsContrastStandard, Label: "Standard", Active: u.settingsDraft.Accessibility.Contrast == contrastStandard}, choiceSpec{Click: &u.settingsContrastHigh, Label: "High Contrast", Active: u.settingsDraft.Accessibility.Contrast == contrastHigh}, ) }) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { return u.settingsPreferenceCard(gtx, "Reduced Motion", "Keep transient status toasts steady instead of auto-dismissing after a short timeout.", func(gtx layout.Context) layout.Dimensions { return u.settingsChoiceRow( gtx, choiceSpec{Click: &u.settingsReducedMotionOff, Label: "Off", Active: !u.settingsDraft.Accessibility.ReducedMotion}, choiceSpec{Click: &u.settingsReducedMotionOn, Label: "On", Active: u.settingsDraft.Accessibility.ReducedMotion}, ) }) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { return u.settingsPreferenceCard(gtx, "Keyboard Focus", "Strengthen the visible focus ring and focused selection treatment for keyboard-first navigation.", func(gtx layout.Context) layout.Dimensions { return u.settingsChoiceRow( gtx, choiceSpec{Click: &u.settingsKeyboardFocusStandard, Label: "Standard", Active: u.settingsDraft.Accessibility.KeyboardFocus == keyboardFocusStandard}, choiceSpec{Click: &u.settingsKeyboardFocusProminent, Label: "Prominent", Active: u.settingsDraft.Accessibility.KeyboardFocus == keyboardFocusProminent}, ) }) }, layout.Spacer{Height: unit.Dp(12)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(14), "Choose how KeePassGO remembers UI layout behavior, sync defaults, and KDBX security defaults without crowding the main vault flow.") lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(12)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(16), "UI Preferences") lbl.Color = accentColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(6)}.Layout, func(gtx layout.Context) layout.Dimensions { check := material.CheckBox(u.theme, &u.settingsGroupControls, "Keep Group Tools collapsed") return check.Layout(gtx) }, func(gtx layout.Context) layout.Dimensions { check := material.CheckBox(u.theme, &u.settingsLifecycleAdvanced, "Keep advanced lifecycle controls collapsed") return check.Layout(gtx) }, func(gtx layout.Context) layout.Dimensions { check := material.CheckBox(u.theme, &u.settingsHistory, "Keep entry history collapsed") return check.Layout(gtx) }, func(gtx layout.Context) layout.Dimensions { check := material.CheckBox(u.theme, &u.settingsDebugHeaderBounds, "Log compact header button bounds") return check.Layout(gtx) }, layout.Spacer{Height: unit.Dp(4)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "Write compact Android header button screen coordinates to the app log so emulator taps can read exact bounds from logcat.") lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(14)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(16), "Vault Security") lbl.Color = accentColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(8)}.Layout, labeledEditorHelp(u.theme, "Cipher", "Supported values: "+strings.Join([]string{vault.CipherAES256, vault.CipherChaCha20}, ", "), &u.securityCipher, false), layout.Spacer{Height: unit.Dp(8)}.Layout, labeledEditorHelp(u.theme, "KDF", "Supported values: "+strings.Join([]string{vault.KDFAES, vault.KDFArgon2}, ", "), &u.securityKDF, false), layout.Spacer{Height: unit.Dp(12)}.Layout, syncDialogSectionLabel(u.theme, "Sync Defaults"), layout.Spacer{Height: unit.Dp(6)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(13), "Advanced Sync starts from these defaults. You can still change the source or direction before a single run.") lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncChoiceButton(gtx, u.theme, &u.showSettingsSyncPull, "Pull Into Current Vault", u.settingsDraft.Sync.DirectionDefault == syncDirectionPull) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncChoiceButton(gtx, u.theme, &u.showSettingsSyncPush, "Push Current Vault Out", u.settingsDraft.Sync.DirectionDefault == syncDirectionPush) }), ) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncChoiceButton(gtx, u.theme, &u.showSettingsSyncLocal, "Local File", u.settingsDraft.Sync.SourceDefault == syncSourceLocal) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncChoiceButton(gtx, u.theme, &u.showSettingsSyncRemote, "Remote WebDAV", u.settingsDraft.Sync.SourceDefault == syncSourceRemote) }), ) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { return syncDialogSummaryCard(gtx, u.theme, syncDialogPurposeAdvanced, u.settingsDraft.Sync.SourceDefault, u.settingsDraft.Sync.DirectionDefault) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { check := material.CheckBox(u.theme, &u.settingsAutoSaveRemote, "Auto-save remote vault edits") return check.Layout(gtx) }, layout.Spacer{Height: unit.Dp(4)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "When enabled, edits to an already-open remote vault save immediately instead of waiting for an explicit remote save.") lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "Conflict handling stays retry-safe: merged entry changes keep history, while remote save conflicts still require reopening the vault and retrying the save.") lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(12)}.Layout, syncDialogSectionLabel(u.theme, "Background Sync"), layout.Spacer{Height: unit.Dp(6)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "Future background sync controls belong here so source, direction, and unattended behavior stay in one settings surface.") lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(12)}.Layout, syncDialogSectionLabel(u.theme, "Feedback"), layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(14), "Success and reminder banners") lbl.Color = accentColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(4)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "Choose how long noncritical status banners stay visible.") lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(6)}.Layout, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncChoiceButton(gtx, u.theme, &u.setStatusBannerShort, "Short", u.statusBannerTTL == 2*time.Second) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncChoiceButton(gtx, u.theme, &u.setStatusBannerStandard, "Standard", u.statusBannerTTL == statusBannerDuration) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncChoiceButton(gtx, u.theme, &u.setStatusBannerLong, "Long", u.statusBannerTTL == statusBannerLong) }), ) }, layout.Spacer{Height: unit.Dp(10)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(14), "Autofill notices") lbl.Color = accentColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(4)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "Keep recent autofill results visible, reduce them to approval-only, or hide them.") lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(6)}.Layout, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncChoiceButton(gtx, u.theme, &u.showAllAutofillNotices, "All", u.autofillNoticePreference == autofillNoticeAll) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncChoiceButton(gtx, u.theme, &u.showApprovalAutofillOnly, "Approval Only", u.autofillNoticePreference == autofillNoticeApprovals) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncChoiceButton(gtx, u.theme, &u.hideAutofillNotices, "Hidden", u.autofillNoticePreference == autofillNoticeSuppressed) }), ) }, layout.Spacer{Height: unit.Dp(12)}.Layout, syncDialogSectionLabel(u.theme, "Privacy"), layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { return settingsSummaryCard(gtx, u.theme, "PRIVACY PLAN", "Use first-fill approval plus browser/app rules to keep autofill constrained to trusted targets.") }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(14), "First-fill approval") lbl.Color = accentColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(4)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), u.autofillFirstFillApprovalSummary()) lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(6)}.Layout, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncChoiceButton(gtx, u.theme, &u.showAutofillApprovalAsk, "Ask First", u.autofillFirstFillApprovalMode == autofillFirstFillApprovalAsk) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncChoiceButton(gtx, u.theme, &u.showAutofillApprovalAllow, "Allow First Fill", u.autofillFirstFillApprovalMode == autofillFirstFillApprovalAllow) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return syncChoiceButton(gtx, u.theme, &u.showAutofillApprovalBlock, "Block Until Allowed", u.autofillFirstFillApprovalMode == autofillFirstFillApprovalBlock) }), ) }, layout.Spacer{Height: unit.Dp(10)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), fmt.Sprintf("%d autofill rule entries configured across browsers, apps, and package-specific overrides.", u.autofillRuleCount())) lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(8)}.Layout, labeledEditorHelp(u.theme, "Browser allowlist", "One origin or hostname per line for trusted browser surfaces.", &u.autofillBrowserAllowlist, false), layout.Spacer{Height: unit.Dp(8)}.Layout, labeledEditorHelp(u.theme, "App and package allowlist", "One Android package name or trusted app identifier per line.", &u.autofillAppAllowlist, false), layout.Spacer{Height: unit.Dp(8)}.Layout, labeledEditorHelp(u.theme, "Package rules", "One rule per line, for example `com.android.chrome=hostname` or `org.keepassgo.browser=view-id`.", &u.autofillPackageRules, false), func(gtx layout.Context) layout.Dimensions { if u.usesCompactViewport() { return layout.Dimensions{} } return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.closeSecuritySettings, "Close") }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.saveSecuritySettings, "Save Settings") }), ) }, } return material.List(u.theme, &u.securityDialogList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { return rows[i](gtx) }) } func (u *ui) remotePrefsDialogContent(gtx layout.Context) layout.Dimensions { rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(20), "Remote Connection Settings & Help") lbl.Color = accentColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(14), "Use Recent Connections to reopen WebDAV-backed vaults quickly without cluttering the main open flow.") lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(12)}.Layout, func(gtx layout.Context) layout.Dimensions { return approvalFact(u.theme, "Current", u.remotePreferencesCurrentSummary(), "")(gtx) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { return approvalFact(u.theme, "Always Saved", u.remotePreferencesAlwaysSavedSummary(), "")(gtx) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { return approvalFact(u.theme, "Retention", u.remotePreferencesRetentionSummary(), "")(gtx) }, layout.Spacer{Height: unit.Dp(8)}.Layout, func(gtx layout.Context) layout.Dimensions { return approvalFact(u.theme, "How Persistence Works", u.remotePreferencesPersistenceSummary(), "")(gtx) }, layout.Spacer{Height: unit.Dp(14)}.Layout, func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.closeRemotePrefsHelp, "Done") }, } return material.List(u.theme, &u.remotePrefsDialogList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { return rows[i](gtx) }) } func (u *ui) approvalDialog(gtx layout.Context) layout.Dimensions { return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { paint.FillShape(gtx.Ops, color.NRGBA{A: 110}, clip.Rect{Max: gtx.Constraints.Max}.Op()) return layout.Dimensions{Size: gtx.Constraints.Max} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { width := gtx.Dp(unit.Dp(640)) if width > gtx.Constraints.Max.X { width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24)) } if width < 1 { width = gtx.Constraints.Max.X } gtx.Constraints.Min.X = width gtx.Constraints.Max.X = width return card(gtx, u.approvalDialogContent) }) }), ) } func (u *ui) approvalDialogContent(gtx layout.Context) layout.Dimensions { request, ok := u.pendingApproval() if !ok { return layout.Dimensions{} } resourceText := approvalResourceText(request) return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(20), "Approve API Request") 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(14), "An external tool requested vault access that is not explicitly allowed or denied.") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return approvalFact(u.theme, "Client", strings.TrimSpace(request.ClientName), strings.TrimSpace(request.TokenName))(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return approvalFact(u.theme, "Operation", string(request.Operation), resourceText)(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout), 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 layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.allowApprovalOnce, "Allow Once") }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.allowApprovalPermanent, "Allow Permanently") }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.denyApprovalOnce, "Deny Once") }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.denyApprovalPermanent, "Deny Permanently") }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.cancelApproval, "Cancel") }), ) }), ) } func (u *ui) pendingApproval() (apiapproval.Request, bool) { pending := u.state.PendingApprovals() if len(pending) == 0 { return apiapproval.Request{}, false } return pending[0], true } func (u *ui) resolvePendingApproval(outcome apiapproval.Outcome) error { request, ok := u.pendingApproval() if !ok { return fmt.Errorf("no pending approval") } return u.state.ResolveApproval(request.ID, outcome) } func approvalResourceText(request apiapproval.Request) string { switch request.Resource.Kind { case apitokens.ResourceEntry: if strings.TrimSpace(request.Resource.EntryID) != "" { return "Entry " + request.Resource.EntryID } case apitokens.ResourceGroup: if len(request.Resource.Path) > 0 { return apiui.FormatResourcePath(request.Resource.Path) } } return "Vault root" } func approvalFact(theme *material.Theme, title, primary, secondary string) layout.Widget { return 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(theme, unit.Sp(12), strings.ToUpper(title)) 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(theme, unit.Sp(16), strings.TrimSpace(primary)) lbl.Color = theme.Palette.Fg return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if strings.TrimSpace(secondary) == "" { return layout.Dimensions{} } lbl := material.Label(theme, unit.Sp(13), strings.TrimSpace(secondary)) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) } } func (u *ui) aboutDetailPanel(gtx layout.Context) layout.Dimensions { rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(22), "KeePassGO") lbl.Color = accentColor return lbl.Layout(gtx) }, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(15), "A KeePass-compatible password manager built in Go.") lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(14)}.Layout, aboutFact(u.theme, "Compatibility", "KeePass and KDBX interoperability", "Designed to coexist with desktop KeePass and KeePass2Android workflows."), layout.Spacer{Height: unit.Dp(10)}.Layout, aboutFact(u.theme, "Platforms", "Windows and Linux first, Android supported", "Desktop remains the primary product surface while Android stays compatible."), layout.Spacer{Height: unit.Dp(10)}.Layout, aboutFact(u.theme, "Sync", "Local files and direct WebDAV", "Remote-file workflows are first-class and avoid browser-stack dependencies."), layout.Spacer{Height: unit.Dp(10)}.Layout, aboutFact(u.theme, "Programmatic Access", "Secure local gRPC API", "Built for trusted clients such as browser extensions and automation."), layout.Spacer{Height: unit.Dp(14)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "Version") lbl.Color = mutedColor return lbl.Layout(gtx) }, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(14), currentAppVersion()) lbl.Color = u.theme.Palette.Fg return lbl.Layout(gtx) }, } return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { return rows[i](gtx) }) } func aboutFact(theme *material.Theme, title, primary, secondary string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return compactCard(gtx, 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(theme, unit.Sp(12), strings.ToUpper(title)) 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(theme, unit.Sp(16), primary) lbl.Color = theme.Palette.Fg return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(theme, unit.Sp(13), secondary) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) }) }) } } func (u *ui) sectionSpacing() unit.Dp { if u.usesCompactViewport() { if u.denseLayout { return unit.Dp(4) } return unit.Dp(6) } if u.denseLayout { return unit.Dp(8) } return unit.Dp(12) } func (u *ui) entryRowMetrics() (unit.Dp, unit.Sp, unit.Sp, unit.Sp, unit.Sp, unit.Dp) { inset := unit.Dp(12) titleSize := unit.Sp(17) metaSize := unit.Sp(14) urlSize := unit.Sp(12) pathSize := unit.Sp(11) dividerGap := unit.Dp(7) if u.denseLayout { inset = unit.Dp(9) titleSize = unit.Sp(15) metaSize = unit.Sp(12) urlSize = unit.Sp(11) pathSize = unit.Sp(10) dividerGap = unit.Dp(5) } if u.usesCompactViewport() { inset = unit.Dp(9) titleSize = unit.Sp(15) metaSize = unit.Sp(12) urlSize = unit.Sp(11) pathSize = unit.Sp(10) dividerGap = unit.Dp(5) if u.denseLayout { inset = unit.Dp(8) titleSize = unit.Sp(14) metaSize = unit.Sp(11) urlSize = unit.Sp(10) pathSize = unit.Sp(9) dividerGap = unit.Dp(4) } } return inset, titleSize, metaSize, urlSize, pathSize, dividerGap } func (u *ui) listPanelTopSections() []listlayout.TopSection { sections := make([]listlayout.TopSection, 0, 6) if u.state.Section != appstate.SectionAbout { sections = append(sections, listlayout.TopSearch) } if !u.isVaultLocked() { sections = append(sections, listlayout.TopNavigation) } if !u.isVaultLocked() && (u.state.Section == appstate.SectionEntries || u.state.Section == appstate.SectionRecycleBin) { sections = append(sections, listlayout.TopPath) } if !u.isVaultLocked() && u.state.Section == appstate.SectionEntries { sections = append(sections, listlayout.TopGroup, listlayout.TopGroupTools) } if !u.isVaultLocked() { sections = append(sections, listlayout.TopPrimary) } return sections } func (u *ui) listPanelSearchRow(gtx layout.Context) layout.Dimensions { if u.state.Section == appstate.SectionAbout { return layout.Dimensions{} } if u.usesCompactViewport() { gtx.Constraints.Min.X = gtx.Constraints.Max.X } return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions { editor := material.Editor(u.theme, &u.search, u.searchPlaceholder()) editor.Color = u.theme.Palette.Fg editor.HintColor = mutedColor return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout) }) } func (u *ui) listPanelPrimaryActionRow(gtx layout.Context) layout.Dimensions { if u.state.Section == appstate.SectionAbout { return layout.Dimensions{} } if u.isVaultLocked() { return layout.Dimensions{} } switch u.state.Section { case appstate.SectionEntries: label := "Add Entry" if u.usesCompactViewport() { label = "+ " + label } btn := material.Button(u.theme, &u.addEntry, label) return btn.Layout(gtx) case appstate.SectionAPITokens: return tonedButton(gtx, u.theme, &u.issueAPIToken, "Issue API Token") default: return layout.Dimensions{} } } func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { panel := card if u.usesCompactViewport() { panel = compactCard } u.ensureNavClickables() if u.usesCompactViewport() { return panel(gtx, u.compactListPanel) } return panel(gtx, u.wideListPanel) } func (u *ui) compactListPanel(gtx layout.Context) layout.Dimensions { visibleEntries, entryClicks := u.visibleEntrySnapshot() rows := u.compactListPanelRows(visibleEntries, entryClicks) return material.List(u.theme, &u.phonePanelList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { return rows[i](gtx) }) } func (u *ui) compactListPanelRows(visibleEntries []entry, entryClicks []*widget.Clickable) []layout.Widget { rows := u.compactListPanelTopRows() switch { case u.state.Section == appstate.SectionAPITokens: rows = append(rows, u.apiTokenListPanel) case u.state.Section == appstate.SectionAPIAudit: rows = append(rows, u.apiAuditListPanel) case u.state.Section == appstate.SectionAbout: case len(visibleEntries) == 0: rows = append(rows, func(gtx layout.Context) layout.Dimensions { return emptyStatePanel(gtx, u.theme, u.listEmptyState()) }) default: for i := range visibleEntries { idx := i rows = append(rows, func(gtx layout.Context) layout.Dimensions { return u.entryRow(gtx, entryClicks[idx], idx, visibleEntries[idx]) }) } } return rows } func (u *ui) compactListPanelTopRows() []layout.Widget { spacing := u.sectionSpacing() rows := make([]layout.Widget, 0, 16) for _, section := range u.listPanelTopSections() { rows = append(rows, u.listPanelTopSectionWidget(section)) rows = append(rows, func(gtx layout.Context) layout.Dimensions { return layout.Spacer{Height: spacing}.Layout(gtx) }) } return rows } func (u *ui) listPanelTopSectionWidget(section listlayout.TopSection) layout.Widget { switch section { case listlayout.TopSearch: return u.listPanelSearchRow case listlayout.TopNavigation: return u.navigationHeader case listlayout.TopPath: return u.pathBar case listlayout.TopGroup: return u.groupBar case listlayout.TopGroupTools: return u.groupControlsSection case listlayout.TopPrimary: return u.listPanelPrimaryActionRow default: return func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} } } } func (u *ui) wideListPanel(gtx layout.Context) layout.Dimensions { children := u.wideListPanelChildren() return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) } func (u *ui) wideListPanelChildren() []layout.FlexChild { spacing := u.sectionSpacing() children := make([]layout.FlexChild, 0, 16) for _, section := range u.listPanelTopSections() { children = append(children, layout.Rigid(u.wideListPanelTopSectionWidget(section))) children = append(children, layout.Rigid(layout.Spacer{Height: spacing}.Layout)) } children = append(children, layout.Flexed(1, u.wideListPanelBody)) return children } func (u *ui) wideListPanelTopSectionWidget(section listlayout.TopSection) layout.Widget { switch section { case listlayout.TopSearch: return u.listPanelSearchRow case listlayout.TopNavigation: return func(gtx layout.Context) layout.Dimensions { if u.isVaultLocked() { return layout.Dimensions{} } return u.navigationHeader(gtx) } case listlayout.TopPath: return func(gtx layout.Context) layout.Dimensions { if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) { return layout.Dimensions{} } return u.pathBar(gtx) } case listlayout.TopGroup: return func(gtx layout.Context) layout.Dimensions { if u.isVaultLocked() || u.state.Section != appstate.SectionEntries { return layout.Dimensions{} } return u.groupBar(gtx) } case listlayout.TopGroupTools: return func(gtx layout.Context) layout.Dimensions { if u.isVaultLocked() || u.state.Section != appstate.SectionEntries { return layout.Dimensions{} } return u.groupControlsSection(gtx) } case listlayout.TopPrimary: return u.listPanelPrimaryActionRow default: return func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} } } } func (u *ui) wideListPanelBody(gtx layout.Context) layout.Dimensions { switch { case u.state.Section == appstate.SectionAPITokens: return u.apiTokenListPanel(gtx) case u.state.Section == appstate.SectionAPIAudit: return u.apiAuditListPanel(gtx) case u.state.Section == appstate.SectionAbout, len(u.visible) == 0: return emptyStatePanel(gtx, u.theme, u.listEmptyState()) default: return material.List(u.theme, &u.list).Layout(gtx, len(u.visible), func(gtx layout.Context, i int) layout.Dimensions { item := u.visible[i] click := &u.entryClicks[i] return u.entryRow(gtx, click, i, item) }) } } func (u *ui) navigationHeader(gtx layout.Context) layout.Dimensions { if u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionAbout { return layout.Dimensions{} } if u.state.Section == appstate.SectionAbout { lbl := material.Label(u.theme, unit.Sp(18), "About") lbl.Color = accentColor return lbl.Layout(gtx) } return u.groupControlsDisclosure(gtx) } func (u *ui) navigationHeaderLabel() string { if u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionAbout { return "" } if u.state.Section == appstate.SectionAbout { return "About" } return "Group Tools" } func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item entry) layout.Dimensions { for click.Clicked(gtx) { 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 focused := u.isFocused(listFocusID(idx)) rowColors := u.listRowColors(selected, focused, u.state.Section == appstate.SectionRecycleBin) row := func(gtx layout.Context) layout.Dimensions { return u.entryRowContent(gtx, item, rowColors) } if selected || focused { return u.highlightedEntryRow(gtx, rowColors, row) } return u.standardEntryRow(gtx, row) }) } func (u *ui) entryRowContent(gtx layout.Context, item entry, rowColors listRowColors) layout.Dimensions { inset, titleSize, metaSize, urlSize, pathSize, dividerGap := u.entryRowMetrics() showPath := strings.TrimSpace(u.search.Text()) != "" || len(u.displayPath()) == 0 || u.state.Section == appstate.SectionRecycleBin hasUsername := strings.TrimSpace(item.Username) != "" hasURL := strings.TrimSpace(item.URL) != "" pathText := strings.Join(u.displayEntryPath(item.Path), " / ") return layout.UniformInset(inset).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, titleSize, item.Title) lbl.Color = rowColors.Title return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !hasUsername { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(3)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !hasUsername { return layout.Dimensions{} } lbl := material.Label(u.theme, metaSize, item.Username) lbl.Color = rowColors.Meta return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !hasURL { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(2)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !hasURL { return layout.Dimensions{} } lbl := material.Label(u.theme, urlSize, item.URL) lbl.Color = rowColors.Secondary return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showPath { return layout.Dimensions{} } return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showPath { return layout.Dimensions{} } lbl := material.Label(u.theme, pathSize, pathText) lbl.Color = rowColors.Secondary return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: dividerGap}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { w := gtx.Constraints.Max.X if w < 1 { w = 1 } paint.FillShape(gtx.Ops, rowColors.Divider, clip.Rect{Max: image.Pt(w, 1)}.Op()) return layout.Dimensions{Size: image.Pt(w, 1)} }), ) }) } func (u *ui) highlightedEntryRow(gtx layout.Context, rowColors listRowColors, row layout.Widget) layout.Dimensions { return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { size := gtx.Constraints.Min if size.X == 0 { size.X = gtx.Constraints.Max.X } if size.Y == 0 { size.Y = gtx.Constraints.Max.Y } paint.FillShape(gtx.Ops, rowColors.Fill, clip.Rect{Max: size}.Op()) paint.FillShape(gtx.Ops, rowColors.Edge, clip.Rect{Max: image.Pt(5, size.Y)}.Op()) return layout.Dimensions{Size: size} }), layout.Stacked(row), ) } func (u *ui) standardEntryRow(gtx layout.Context, row layout.Widget) layout.Dimensions { bg := panelColor if u.state.Section == appstate.SectionRecycleBin { bg = color.NRGBA{R: 249, G: 242, B: 236, A: 255} } return layout.Background{}.Layout(gtx, fill(bg), row) } func (u *ui) phoneSlider(gtx layout.Context) layout.Dimensions { if !u.usesCompactViewport() { return layout.Dimensions{} } for { e, ok := u.splitDrag.Update(gtx.Metric, gtx.Source, gesture.Vertical) if !ok { break } switch e.Kind { case pointer.Press: u.splitBase = u.phoneSplit.Value u.splitStartY = e.Position.Y case pointer.Drag: if u.phoneSpan > 0 { next := u.splitBase + (e.Position.Y-u.splitStartY)/float32(u.phoneSpan) if next < 0.28 { next = 0.28 } if next > 0.72 { next = 0.72 } u.phoneSplit.Value = next } } } gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(24)) gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(24)) return layout.UniformInset(unit.Dp(2)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop() u.splitDrag.Add(gtx.Ops) pointer.CursorRowResize.Add(gtx.Ops) handleW := gtx.Dp(unit.Dp(108)) handleH := gtx.Dp(unit.Dp(6)) x := (gtx.Constraints.Min.X - handleW) / 2 y := (gtx.Constraints.Min.Y - handleH) / 2 paint.FillShape(gtx.Ops, color.NRGBA{R: 214, G: 208, B: 197, A: 255}, clip.Rect{Min: image.Pt(0, y+2), Max: image.Pt(gtx.Constraints.Min.X, y+3)}.Op()) paint.FillShape(gtx.Ops, accentColor, clip.RRect{ Rect: image.Rectangle{Min: image.Pt(x, y), Max: image.Pt(x+handleW, y+handleH)}, NE: 2, NW: 2, SE: 2, SW: 2, }.Op(gtx.Ops)) return layout.Dimensions{Size: gtx.Constraints.Min} }) } func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions { panel := card if u.usesCompactViewport() { panel = compactCard } return panel(gtx, func(gtx layout.Context) layout.Dimensions { if u.shouldShowDesktopWorkingHeader() { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(u.headerActions), layout.Rigid(u.desktopHeaderMenus), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return u.detailPanelContent(gtx) }), ) } return u.detailPanelContent(gtx) }) } func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions { _, 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) switch mode { case detaillayout.ModeLocked: return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.lockedDetailChildren()...) case detaillayout.ModeStatic: panel := u.staticDetailPanel() return layout.Flex{Axis: layout.Vertical}.Layout(gtx, panel...) case detaillayout.ModeEmpty: return layout.Dimensions{} } item, ok := u.selectedEntry() if mode == detaillayout.ModeEditor { return u.detailEditorContent(gtx, ok) } return u.detailViewContent(gtx, item) } func (u *ui) lockedDetailChildren() []layout.FlexChild { return []layout.FlexChild{ layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(18), "Unlock Vault") 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(14), "Enter the master password, choose a key file, or provide both.") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(u.unlockPanel), } } func (u *ui) staticDetailPanel() []layout.FlexChild { switch u.state.Section { case appstate.SectionAPITokens: return []layout.FlexChild{layout.Flexed(1, u.apiTokenDetailPanel)} case appstate.SectionAPIAudit: return []layout.FlexChild{layout.Flexed(1, u.apiAuditDetailPanel)} case appstate.SectionAbout: return []layout.FlexChild{layout.Flexed(1, u.aboutDetailPanel)} default: return nil } } func (u *ui) detailEditorContent(gtx layout.Context, hasSelected bool) layout.Dimensions { rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { title := "New Entry" if hasSelected { title = "Edit Entry" } lbl := material.Label(u.theme, unit.Sp(18), title) lbl.Color = accentColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(8)}.Layout, u.entryEditorPanel, } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { return rows[i](gtx) }) }), ) } type detailViewMetrics struct { titleSize unit.Sp titlePad unit.Dp sectionGap unit.Dp cardGap unit.Dp } func (u *ui) detailViewContent(gtx layout.Context, item entry) layout.Dimensions { rows := u.detailViewRows(item) return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { return rows[i](gtx) }) }), ) } func (u *ui) detailViewRows(item entry) []layout.Widget { password := u.detailPasswordValue() metrics := u.detailViewMetrics() rows := []layout.Widget{ u.detailTitleRow(item, metrics), layout.Spacer{Height: metrics.titlePad}.Layout, u.detailCompactCopyRow(item), layout.Spacer{Height: u.recycleDetailGap()}.Layout, u.recycleDetailNotice, layout.Spacer{Height: u.recycleDetailCardGap()}.Layout, u.detailMetadataCard(item, metrics), layout.Spacer{Height: metrics.sectionGap}.Layout, u.detailPasswordCard(password), layout.Spacer{Height: unit.Dp(8)}.Layout, u.detailNotesCard(item), layout.Spacer{Height: metrics.cardGap}.Layout, u.attachmentSummaryPanel, layout.Spacer{Height: metrics.cardGap}.Layout, u.historyPanel, layout.Spacer{Height: metrics.cardGap}.Layout, u.detailActionRow, } return rows } func (u *ui) detailViewMetrics() detailViewMetrics { metrics := detailViewMetrics{ titleSize: unit.Sp(26), titlePad: unit.Dp(10), sectionGap: unit.Dp(6), cardGap: unit.Dp(8), } if u.denseLayout { metrics.titlePad = unit.Dp(6) metrics.sectionGap = unit.Dp(4) metrics.cardGap = unit.Dp(6) } if u.usesCompactViewport() { metrics.titleSize = unit.Sp(18) metrics.titlePad = unit.Dp(4) metrics.sectionGap = unit.Dp(4) metrics.cardGap = unit.Dp(6) if u.denseLayout { metrics.titlePad = unit.Dp(3) metrics.sectionGap = unit.Dp(3) metrics.cardGap = unit.Dp(4) } } return metrics } func (u *ui) detailTitleRow(item entry, metrics detailViewMetrics) layout.Widget { return func(gtx layout.Context) layout.Dimensions { title := item.Title if u.state.Section == appstate.SectionRecycleBin { title = "Recycle Bin Entry" } lbl := material.Label(u.theme, metrics.titleSize, title) lbl.Color = accentColor return lbl.Layout(gtx) } } func (u *ui) detailCompactCopyRow(item entry) layout.Widget { return func(gtx layout.Context) layout.Dimensions { if u.state.Section == appstate.SectionRecycleBin { return recycleDetailTitle(gtx, u.theme, item.Title) } if !u.usesCompactViewport() { return layout.Dimensions{} } return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Flexed(0.5, func(gtx layout.Context) layout.Dimensions { return compactTonedButton(gtx, u.theme, &u.copyUser, "Copy Username") }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Flexed(0.5, func(gtx layout.Context) layout.Dimensions { return compactTonedButton(gtx, u.theme, &u.copyPass, "Copy Password") }), ) } } func (u *ui) recycleDetailGap() unit.Dp { if u.state.Section == appstate.SectionRecycleBin { return unit.Dp(10) } return 0 } func (u *ui) recycleDetailCardGap() unit.Dp { if u.state.Section == appstate.SectionRecycleBin { return unit.Dp(8) } return 0 } func (u *ui) recycleDetailNotice(gtx layout.Context) layout.Dimensions { if u.state.Section != appstate.SectionRecycleBin { return layout.Dimensions{} } return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "This entry is in the recycle bin. Review it, copy from it, or restore it back into the vault.") lbl.Color = mutedColor return lbl.Layout(gtx) }) } func (u *ui) detailMetadataCard(item entry, metrics detailViewMetrics) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(detailLine(u.theme, "Path", strings.Join(u.displayEntryPath(item.Path), " / "))), layout.Rigid(layout.Spacer{Height: metrics.sectionGap}.Layout), layout.Rigid(detailLine(u.theme, "Username", item.Username)), layout.Rigid(layout.Spacer{Height: metrics.sectionGap}.Layout), layout.Rigid(detailLine(u.theme, "URL", item.URL)), layout.Rigid(layout.Spacer{Height: metrics.sectionGap}.Layout), layout.Rigid(detailLine(u.theme, "Tags", strings.Join(item.Tags, ", "))), ) }) } } func (u *ui) detailPasswordCard(password string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(u.passwordLine("Password", password)), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(u.detailCopyActionRow), ) }) } } func (u *ui) detailCopyActionRow(gtx layout.Context) layout.Dimensions { if u.usesCompactViewport() { return compactTonedButton(gtx, u.theme, &u.copyURL, "Copy URL") } return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password") }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyUser, "Copy Username") }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyURL, "Copy URL") }), ) } func (u *ui) detailNotesCard(item entry) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return compactCard(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(12), "NOTES") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Body1(u.theme, item.Notes) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) }) } } func (u *ui) detailActionRow(gtx layout.Context) layout.Dimensions { switch u.state.Section { case appstate.SectionTemplates: return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.editEntry, "Edit Template") }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.instantiateTemplate, "Instantiate") }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.deleteTemplate, "Delete Template") }), ) case appstate.SectionRecycleBin: return tonedButton(gtx, u.theme, &u.restoreEntry, "Restore Entry To Vault") default: return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.editEntry, "Edit") }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.duplicateEntry, "Duplicate") }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.deleteEntry, "Delete") }), ) } } func (u *ui) banner(gtx layout.Context) layout.Dimensions { banner := u.bannerSurface() if banner.Kind == bannerNone { return layout.Dimensions{} } bg := color.NRGBA{R: 232, G: 239, B: 235, A: 255} fg := accentColor switch banner.Kind { case bannerLoading: bg = color.NRGBA{R: 234, G: 232, B: 227, A: 255} fg = color.NRGBA{R: 92, G: 76, B: 34, A: 255} case bannerError: bg = color.NRGBA{R: 248, G: 228, B: 225, A: 255} fg = color.NRGBA{R: 130, G: 36, B: 25, A: 255} } primaryAction, secondaryAction := u.bannerActionLabels(banner) return layout.Background{}.Layout(gtx, fill(bg), func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(12)).Layout(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 layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(14), banner.Message) lbl.Color = fg return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if strings.TrimSpace(banner.Detail) == "" { return layout.Dimensions{} } return layout.Inset{Top: unit.Dp(2)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), banner.Detail) lbl.Color = fg return lbl.Layout(gtx) }) }), ) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if primaryAction == "" && secondaryAction == "" { return layout.Dimensions{} } return layout.Inset{Left: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { if primaryAction == "" { return layout.Dimensions{} } click := &u.cancelLifecycleProgress if primaryAction == "Retry" { click = &u.retryLifecycleOpen } return tonedButton(gtx, u.theme, click, primaryAction) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if primaryAction == "" || secondaryAction == "" { return layout.Dimensions{} } return layout.Spacer{Width: unit.Dp(6)}.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if secondaryAction == "" { return layout.Dimensions{} } return tonedButton(gtx, u.theme, &u.dismissBanner, secondaryAction) }), ) }) }), ) }) }) } func (u *ui) statusToast(gtx layout.Context) layout.Dimensions { status := u.statusToastSurface() if status.Kind == bannerNone { return layout.Dimensions{} } max := gtx.Constraints.Max gtx.Constraints.Min = max return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y)} }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.End}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return statusToastCard(gtx, u.theme, status.Message) }), ) }), ) }) } func (u *ui) autofillStatusCard(gtx layout.Context) layout.Dimensions { status := u.autofillStatusSurface() if status.Kind == autofillStatusNone { return layout.Dimensions{} } bg := color.NRGBA{R: 233, G: 241, B: 237, A: 255} accent := accentColor switch status.Kind { case autofillStatusAmbiguous: bg = color.NRGBA{R: 245, G: 239, B: 223, A: 255} accent = color.NRGBA{R: 117, G: 88, B: 24, A: 255} case autofillStatusBlocked: bg = color.NRGBA{R: 247, G: 232, B: 228, A: 255} accent = color.NRGBA{R: 125, G: 40, B: 30, A: 255} case autofillStatusAwaitingApproval: bg = color.NRGBA{R: 229, G: 236, B: 244, A: 255} accent = color.NRGBA{R: 30, G: 76, B: 128, A: 255} } return layout.Background{}.Layout(gtx, fill(bg), func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Start}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Inset{Right: unit.Dp(12), Top: unit.Dp(2)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { label := material.Label(u.theme, unit.Sp(12), "Autofill") label.Color = accent label.Font.Weight = 600 return label.Layout(gtx) }) }), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { label := material.Label(u.theme, unit.Sp(14), status.Title) label.Color = accent label.Font.Weight = 600 return label.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Inset{Top: unit.Dp(2)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { label := material.Label(u.theme, unit.Sp(12), status.Message) label.Color = color.NRGBA{R: 52, G: 50, B: 46, A: 255} return label.Layout(gtx) }) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if strings.TrimSpace(status.Detail) == "" { return layout.Dimensions{} } return layout.Inset{Top: unit.Dp(2)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { label := material.Label(u.theme, unit.Sp(11), status.Detail) label.Color = mutedColor return label.Layout(gtx) }) }), ) }), ) }) }) } func (u *ui) historyPanel(gtx layout.Context) layout.Dimensions { history := u.visibleHistory() u.ensureHistoryClickables() children := []layout.FlexChild{ layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.toggleHistory.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { icon := u.expandLessIcon if u.historyHidden { icon = u.expandMoreIcon } if icon != nil { return icon.Layout(gtx, accentColor) } lbl := material.Label(u.theme, unit.Sp(16), ">") if !u.historyHidden { lbl.Text = "v" } lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(14), "History") lbl.Color = accentColor return lbl.Layout(gtx) }), ) }) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), } if u.historyHidden { children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), fmt.Sprintf("%d saved version(s).", len(history))) lbl.Color = mutedColor return lbl.Layout(gtx) })) return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) } if len(history) == 0 { children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "No history for this entry yet.") lbl.Color = mutedColor return lbl.Layout(gtx) })) return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) } for i := range history { index := i children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.historyRow(gtx, &u.historyClicks[index], index, history[index]) })) children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) } if selected, ok := u.selectedHistoryEntry(); ok { children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "Selected Version") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(detailLine(u.theme, "Path", strings.Join(selected.Path, " / "))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(detailLine(u.theme, "Username", selected.Username)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(detailLine(u.theme, "URL", selected.URL)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Body2(u.theme, selected.Notes) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) } func (u *ui) attachmentSummaryPanel(gtx layout.Context) layout.Dimensions { items := u.selectedAttachmentItems() return compactCard(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(12), "ATTACHMENTS") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(11), u.attachmentActionSummary()) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if len(items) == 0 { return layout.Dimensions{} } return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild { children := make([]layout.FlexChild, 0, len(items)*2) selectedName := strings.TrimSpace(u.attachmentName.Text()) for i, item := range items { index := i name := item.Name selected := selectedName == name children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { for u.attachmentClicks[index].Clicked(gtx) { u.attachmentName.SetText(name) } return u.attachmentClicks[index].Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { size := gtx.Constraints.Min if size.X == 0 { size.X = gtx.Constraints.Max.X } if size.Y == 0 { size.Y = gtx.Dp(unit.Dp(58)) } bg := panelColor if selected { bg = selectedColor } paint.FillShape(gtx.Ops, bg, clip.Rect{Max: size}.Op()) if selected { paint.FillShape(gtx.Ops, selectedEdge, clip.Rect{Max: image.Pt(4, size.Y)}.Op()) } return layout.Dimensions{Size: size} }), layout.Stacked(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(12), name) lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { text := fmt.Sprintf("%d B", item.Size) if selected { text += " · selected" } lbl := material.Label(u.theme, unit.Sp(11), text) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) }) }), ) }) })) if i < len(items)-1 { children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) } } return children }()...) }) }), ) }) } func (u *ui) historyRow(gtx layout.Context, click *widget.Clickable, index int, item entry) layout.Dimensions { for click.Clicked(gtx) { _ = u.selectHistoryVersion(index) } return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions { row := 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), fmt.Sprintf("Version %d", index)) 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), item.Username) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), item.URL) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Body2(u.theme, item.Notes) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) }) } if index == u.selectedHistoryIndex { return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { size := gtx.Constraints.Min if size.X == 0 { size.X = gtx.Constraints.Max.X } if size.Y == 0 { size.Y = gtx.Constraints.Max.Y } paint.FillShape(gtx.Ops, selectedColor, clip.Rect{Max: size}.Op()) paint.FillShape(gtx.Ops, selectedEdge, clip.Rect{Max: image.Pt(4, size.Y)}.Op()) return layout.Dimensions{Size: size} }), layout.Stacked(row), ) } return layout.Background{}.Layout(gtx, fill(panelColor), row) }) } func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { if u.state.Section == appstate.SectionRecycleBin { return recyclePathCard(gtx, u.theme, "Recycle Bin", "Deleted entries stay here until you restore them.") } u.syncCurrentPath() displayPath := u.displayPath() pathSource := displayPath if u.state.Section == appstate.SectionTemplates { pathSource = append([]string{}, u.currentPath...) } crumbs, indices := u.visibleBreadcrumbs(pathSource) crumbBar := func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, func() []layout.FlexChild { children := make([]layout.FlexChild, 0, len(crumbs)*2) for i, name := range crumbs { index := i label := name children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { for u.breadcrumbs[index].Clicked(gtx) { target := indices[index] if target == 0 { root := u.hiddenVaultRoot() if root == "" { u.setCurrentPath(nil) } else { u.setCurrentPath([]string{root}) } } else { nextPath := pathSource[:target] root := u.hiddenVaultRoot() if root != "" { nextPath = append([]string{root}, nextPath...) } u.setCurrentPath(nextPath) } u.filter() } btn := material.Button(u.theme, &u.breadcrumbs[index], label) btn.Background, btn.Color = buttonFocusColors(u.accessibilityPrefs, u.isFocused(breadcrumbFocusID(index))) btn.TextSize = unit.Sp(11) if u.usesCompactViewport() { btn.TextSize = unit.Sp(9) btn.Inset = layout.Inset{Top: 3, Bottom: 3, Left: 6, Right: 6} } else { btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9} } return btn.Layout(gtx) })) if i < len(crumbs)-1 { children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "/") lbl.Color = mutedColor inset := unit.Dp(6) if u.usesCompactViewport() { inset = unit.Dp(4) } return layout.UniformInset(inset).Layout(gtx, lbl.Layout) })) } } return children }()...) } return crumbBar(gtx) } func (u *ui) visibleBreadcrumbs(displayPath []string) ([]string, []int) { if u.state.Section == appstate.SectionTemplates { return append([]string{"Templates"}, append([]string{}, u.currentPath...)...), func() []int { indices := make([]int, 0, len(u.currentPath)+1) indices = append(indices, 0) for i := range u.currentPath { indices = append(indices, i+1) } return indices }() } if !u.usesCompactViewport() || len(displayPath) <= 2 { crumbs := append([]string{"/"}, append([]string{}, displayPath...)...) indices := make([]int, 0, len(crumbs)) indices = append(indices, 0) for i := range displayPath { indices = append(indices, i+1) } return crumbs, indices } crumbs := []string{"/", "…", displayPath[len(displayPath)-1]} indices := []int{0, len(displayPath) - 1, len(displayPath)} return crumbs, indices } func (u *ui) groupBar(gtx layout.Context) layout.Dimensions { groups := append([]string{}, u.childGroups()...) if len(u.groupClicks) < len(groups) { u.groupClicks = make([]widget.Clickable, len(groups)) } return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { if u.usesCompactViewport() { if len(u.displayPath()) == 0 { u.phoneGroupBrowserExpanded = true } children := make([]layout.FlexChild, 0, len(groups)) for i := range groups { idx := i name := groups[i] children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { for u.groupClicks[idx].Clicked(gtx) { u.state.EnterGroup(name) u.adoptStateCurrentPath() u.filter() } return tonedButton(gtx, u.theme, &u.groupClicks[idx], name) }) })) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { if len(groups) == 0 { return layout.Dimensions{} } maxGroupListHeight := 200 if u.usesCompactViewport() { maxGroupListHeight = 96 } maxY := gtx.Dp(unit.Dp(maxGroupListHeight)) if gtx.Constraints.Max.Y > maxY { gtx.Constraints.Max.Y = maxY } if gtx.Constraints.Min.Y > gtx.Constraints.Max.Y { gtx.Constraints.Min.Y = gtx.Constraints.Max.Y } return material.List(u.theme, &u.groupList).Layout(gtx, len(groups), func(gtx layout.Context, i int) layout.Dimensions { idx := i name := groups[i] return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { for u.groupClicks[idx].Clicked(gtx) { u.state.EnterGroup(name) u.adoptStateCurrentPath() u.filter() } return tonedButton(gtx, u.theme, &u.groupClicks[idx], name) }) }) }), ) }) } func (u *ui) groupBarShowsExplicitNavigationButtons() bool { return false } func detailLine(th *material.Theme, label, value string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { valueSize := unit.Sp(16) if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(460)) { valueSize = unit.Sp(15) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(th, unit.Sp(12), strings.ToUpper(label)) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(th, valueSize, value) return lbl.Layout(gtx) }), ) } } func (u *ui) passwordLine(label, value string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { valueSize := unit.Sp(16) if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(460)) { valueSize = unit.Sp(15) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), strings.ToUpper(label)) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, valueSize, value) return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.inlinePasswordToggle(gtx, &u.togglePasswordInline, u.showPassword) }), ) }), ) } } func (u *ui) inlinePasswordToggle(gtx layout.Context, click *widget.Clickable, showing bool) layout.Dimensions { icon, desc := u.passwordTogglePresentation(showing) btn := material.IconButton(u.theme, click, icon, desc) btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255} btn.Color = accentColor btn.Size = unit.Dp(18) btn.Inset = layout.UniformInset(unit.Dp(8)) return btn.Layout(gtx) } func (u *ui) passwordTogglePresentation(showing bool) (*widget.Icon, string) { if showing { return u.eyeIcon, "Hide password" } return u.eyeOffIcon, "Show password" } func (u *ui) detailPasswordValue() string { item, ok := u.selectedEntry() if !ok { return "" } if u.showPassword { return item.Password } return strings.Repeat("•", max(8, len(item.Password))) } func card(gtx layout.Context, w layout.Widget) layout.Dimensions { return layout.Background{}.Layout(gtx, fill(panelColor), func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(16)).Layout(gtx, w) }) } func compactCard(gtx layout.Context, w layout.Widget) layout.Dimensions { return layout.Background{}.Layout(gtx, fill(panelColor), func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(10)).Layout(gtx, w) }) } func emptyStatePanel(gtx layout.Context, th *material.Theme, state emptyState) layout.Dimensions { return compactCard(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(th, unit.Sp(15), state.Title) lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(th, unit.Sp(13), state.Body) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) }) } func outlinedFieldStateWithPrefs(gtx layout.Context, prefs accessibilityPreferences, focused bool, w layout.Widget) layout.Dimensions { appearance := fieldFocusAppearance(gtx.Metric, prefs, focused) size := gtx.Constraints.Min if size.X == 0 { size.X = gtx.Constraints.Max.X } if size.Y == 0 { size.Y = appearance.MinHeight } gtx.Constraints.Min = size return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { paint.FillShape(gtx.Ops, color.NRGBA{R: 255, G: 253, B: 249, A: 255}, clip.Rect{Max: size}.Op()) return layout.Dimensions{Size: size} }), layout.Expanded(func(gtx layout.Context) layout.Dimensions { return drawFocusOutline(gtx, appearance, size) }), layout.Expanded(func(gtx layout.Context) layout.Dimensions { paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Max: image.Pt(size.X, 1)}.Op()) paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Min: image.Pt(0, size.Y-1), Max: image.Pt(size.X, size.Y)}.Op()) paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Max: image.Pt(1, size.Y)}.Op()) paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Min: image.Pt(size.X-1, 0), Max: image.Pt(size.X, size.Y)}.Op()) return layout.Dimensions{Size: size} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { min := gtx.Constraints.Min gtx.Constraints.Min = image.Point{} dims := w(gtx) if dims.Size.X < min.X { dims.Size.X = min.X } if dims.Size.Y < min.Y { dims.Size.Y = min.Y } if dims.Size.Y < appearance.MinHeight { dims.Size.Y = appearance.MinHeight } return dims }), ) } func (u *ui) outlinedFieldState(gtx layout.Context, focused bool, w layout.Widget) layout.Dimensions { return outlinedFieldStateWithPrefs(gtx, u.accessibilityPrefs, focused, w) } func tonedButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string) layout.Dimensions { btn := material.Button(th, click, label) btn.Background, btn.Color = buttonFocusColors(defaultAccessibilityPreferences(), false) btn.CornerRadius = unit.Dp(10) btn.TextSize = unit.Sp(15) if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(460)) { btn.TextSize = unit.Sp(14) btn.Inset = layout.Inset{Top: 7, Bottom: 7, Left: 10, Right: 10} } return btn.Layout(gtx) } func compactTonedButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string) layout.Dimensions { btn := material.Button(th, click, label) btn.Background, btn.Color = buttonFocusColors(defaultAccessibilityPreferences(), false) btn.CornerRadius = unit.Dp(10) btn.TextSize = unit.Sp(13) btn.Inset = layout.Inset{Top: 6, Bottom: 6, Left: 8, Right: 8} return btn.Layout(gtx) } func syncPrimaryButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string, compact bool) layout.Dimensions { btn := material.Button(th, click, label) btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255} btn.Color = accentColor btn.CornerRadius = unit.Dp(10) btn.TextSize = unit.Sp(14) btn.Inset = layout.Inset{Top: 7, Bottom: 7, Left: 12, Right: 12} if compact { btn.TextSize = unit.Sp(13) btn.Inset = layout.Inset{Top: 7, Bottom: 7, Left: 10, Right: 10} } return btn.Layout(gtx) } func syncChoiceButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string, active bool) layout.Dimensions { btn := material.Button(th, click, label) btn.CornerRadius = unit.Dp(10) btn.TextSize = unit.Sp(14) btn.Inset = layout.Inset{Top: 7, Bottom: 7, Left: 11, Right: 11} if active { 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 } return btn.Layout(gtx) } func syncDialogSectionLabel(th *material.Theme, text string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { lbl := material.Label(th, unit.Sp(12), strings.ToUpper(text)) lbl.Color = mutedColor return lbl.Layout(gtx) } } func syncDialogSummaryText(purpose syncDialogPurpose, source syncSourceMode, direction syncDirection) string { return syncmodel.SummaryText(purpose, source, direction) } func syncDialogSummaryCard(gtx layout.Context, th *material.Theme, purpose syncDialogPurpose, source syncSourceMode, direction syncDirection) 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(th, unit.Sp(12), "SYNC PLAN") 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(th, unit.Sp(14), syncDialogSummaryText(purpose, source, direction)) lbl.Color = th.Palette.Fg return lbl.Layout(gtx) }), ) }) }) } func recyclePathCard(gtx layout.Context, th *material.Theme, title, body string) layout.Dimensions { return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 247, G: 239, B: 231, 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(th, unit.Sp(12), strings.ToUpper(title)) lbl.Color = color.NRGBA{R: 144, G: 74, B: 49, A: 255} return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(th, unit.Sp(13), body) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) }) }) } func recycleDetailTitle(gtx layout.Context, th *material.Theme, title string) layout.Dimensions { return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 247, G: 239, B: 231, 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(th, unit.Sp(12), "DELETED ENTRY") lbl.Color = color.NRGBA{R: 144, G: 74, B: 49, A: 255} return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(th, unit.Sp(16), title) return lbl.Layout(gtx) }), ) }) }) } func statusToastCard(gtx layout.Context, th *material.Theme, message string) layout.Dimensions { return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 27, G: 58, B: 47, A: 235}), func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(th, unit.Sp(13), message) lbl.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} return lbl.Layout(gtx) }) }) } func sectionTabButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string, active bool) layout.Dimensions { btn := material.Button(th, click, label) btn.CornerRadius = unit.Dp(10) btn.TextSize = unit.Sp(11) btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9} if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(460)) { btn.TextSize = unit.Sp(9) btn.Inset = layout.Inset{Top: 3, Bottom: 3, Left: 7, Right: 7} } if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(220)) { btn.TextSize = unit.Sp(9) btn.Inset = layout.Inset{Top: 4, Bottom: 4, Left: 6, Right: 6} } if active { btn.Background = accentColor btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} } else { btn.Background = selectedColor btn.Color = accentColor } return btn.Layout(gtx) } func fill(c color.NRGBA) layout.Widget { return func(gtx layout.Context) layout.Dimensions { paint.FillShape(gtx.Ops, c, clip.Rect{Max: gtx.Constraints.Min}.Op()) return layout.Dimensions{Size: gtx.Constraints.Min} } }