package main import ( "fmt" "flag" "image" "image/color" "os" "strings" "git.julianfamily.org/keepassgo/appstate" "git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/session" "git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/webdav" "gioui.org/app" "gioui.org/gesture" "gioui.org/io/pointer" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/layout" "gioui.org/op" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" "golang.org/x/exp/shiny/materialdesign/icons" ) type entry = vault.Entry type ui struct { mode string theme *material.Theme 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 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 historyIndex widget.Editor groupName widget.Editor passwordProfile widget.Editor attachmentName widget.Editor attachmentPath widget.Editor exportAttachmentPath widget.Editor list widget.List detailList widget.List copyUser widget.Clickable copyPass widget.Clickable copyURL widget.Clickable openURL widget.Clickable lockVault widget.Clickable unlockVault widget.Clickable createVault widget.Clickable openVault widget.Clickable saveVault widget.Clickable saveAsVault widget.Clickable openRemote 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 removeAttachment widget.Clickable exportAttachment widget.Clickable restoreHistory widget.Clickable generatePassword widget.Clickable createGroup widget.Clickable renameGroup widget.Clickable deleteGroup widget.Clickable togglePasswordInline widget.Clickable showEntries widget.Clickable showTemplates widget.Clickable showRecycle widget.Clickable entryClicks []widget.Clickable breadcrumbs []widget.Clickable groupClicks []widget.Clickable state appstate.State visible []entry currentPath []string showPassword bool togglePassword widget.Clickable phoneSplit widget.Float splitDrag gesture.Drag splitBase float32 splitStartY float32 phoneSpan int eyeIcon *widget.Icon eyeOffIcon *widget.Icon copyIcon *widget.Icon statusMessage string errorMessage string } 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} ) func newUI(mode string) *ui { return newUIWithSession(mode, &session.Manager{}) } func newUIWithModel(mode string, model vault.Model) *ui { return newUIWithState(mode, &uiSession{model: model}) } func newUIWithSession(mode string, sess appstate.CurrentSession) *ui { return newUIWithState(mode, sess) } func newUIWithState(mode string, sess appstate.CurrentSession) *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, 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}, masterPassword: widget.Editor{SingleLine: true, Submit: false}, keyFilePath: 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}, 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}, list: widget.List{ List: layout.List{Axis: layout.Vertical}, }, detailList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, state: appstate.State{}, } u.state.Session = sess u.phoneSplit.Value = 0.46 u.eyeIcon, _ = widget.NewIcon(icons.ActionVisibility) u.eyeOffIcon, _ = widget.NewIcon(icons.ActionVisibilityOff) u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy) u.passwordProfile.SetText("strong") u.filter() return u } func (u *ui) filter() { u.state.SearchQuery = u.search.Text() u.state.CurrentPath = append([]string(nil), u.currentPath...) visible, err := u.state.VisibleEntries() if err != nil { u.visible = nil return } u.visible = visible if len(u.entryClicks) < len(u.visible) { u.entryClicks = make([]widget.Clickable, len(u.visible)) } } func (u *ui) showEntriesSection() { u.state.Section = appstate.SectionEntries u.currentPath = nil u.filter() } func (u *ui) showTemplatesSection() { u.state.Section = appstate.SectionTemplates u.currentPath = nil u.filter() } func (u *ui) showRecycleBinSection() { u.state.Section = appstate.SectionRecycleBin u.currentPath = nil u.filter() } func (u *ui) childGroups() []string { u.state.SearchQuery = u.search.Text() u.state.CurrentPath = append([]string(nil), u.currentPath...) groups, err := u.state.ChildGroups() if err != nil { return nil } return groups } 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) 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) currentMasterKey() (vault.MasterKey, error) { key := vault.MasterKey{Password: u.masterPassword.Text()} path := strings.TrimSpace(u.keyFilePath.Text()) if path == "" { return key, nil } content, err := os.ReadFile(path) if err != nil { return vault.MasterKey{}, fmt.Errorf("read key file: %w", err) } key.KeyFileData = content return key, nil } func (u *ui) createVaultAction() error { key, err := u.currentMasterKey() if err != nil { return err } if err := u.state.CreateVault(key); err != nil { return err } u.currentPath = nil u.filter() return nil } func (u *ui) openVaultAction() error { key, err := u.currentMasterKey() if err != nil { return err } if err := u.state.OpenVault(strings.TrimSpace(u.vaultPath.Text()), key); err != nil { return err } u.currentPath = nil u.filter() return nil } func (u *ui) saveAction() error { if err := u.state.Save(); err != nil { return err } u.filter() return nil } func (u *ui) saveAsAction() error { if err := u.state.SaveAs(strings.TrimSpace(u.saveAsPath.Text())); err != nil { return err } u.filter() return nil } func (u *ui) openRemoteAction() error { key, err := u.currentMasterKey() if err != nil { return err } client := webdav.Client{ BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), Username: strings.TrimSpace(u.remoteUsername.Text()), Password: u.remotePassword.Text(), } if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil { return err } u.currentPath = nil u.filter() return nil } func (u *ui) lockAction() error { if err := u.state.Lock(); err != nil { return err } u.showPassword = false u.filter() return nil } func (u *ui) unlockAction() error { key, err := u.currentMasterKey() if err != nil { return err } if err := u.state.Unlock(key); err != nil { return err } u.filter() return nil } func (u *ui) runAction(label string, action func() error) { if err := action(); err != nil { u.errorMessage = err.Error() u.statusMessage = "" return } u.errorMessage = "" u.statusMessage = label + " complete" } func (u *ui) ensureNavClickables() { if len(u.breadcrumbs) < len(u.currentPath)+1 { u.breadcrumbs = make([]widget.Clickable, len(u.currentPath)+1) } } func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.processShortcuts(gtx) for u.createVault.Clicked(gtx) { u.runAction("create vault", u.createVaultAction) } for u.openVault.Clicked(gtx) { u.runAction("open vault", u.openVaultAction) } for u.saveVault.Clicked(gtx) { u.runAction("save vault", u.saveAction) } for u.saveAsVault.Clicked(gtx) { u.runAction("save-as vault", u.saveAsAction) } for u.openRemote.Clicked(gtx) { u.runAction("open remote vault", u.openRemoteAction) } for u.unlockVault.Clicked(gtx) { u.runAction("unlock vault", u.unlockAction) } for u.showEntries.Clicked(gtx) { u.showEntriesSection() } for u.showTemplates.Clicked(gtx) { u.showTemplatesSection() } for u.showRecycle.Clicked(gtx) { u.showRecycleBinSection() } for u.lockVault.Clicked(gtx) { u.runAction("lock vault", u.lockAction) } for u.addEntry.Clicked(gtx) { u.state.SelectedEntryID = "" u.loadSelectedEntryIntoEditor() u.entryPath.SetText(strings.Join(u.currentPath, " / ")) u.statusMessage = "new entry form ready" u.errorMessage = "" } for u.saveEntry.Clicked(gtx) { u.runAction("save entry", u.saveEntryAction) } for u.duplicateEntry.Clicked(gtx) { u.runAction("duplicate entry", u.duplicateSelectedEntryAction) } for u.deleteEntry.Clicked(gtx) { u.runAction("delete entry", u.deleteSelectedEntryAction) } for u.restoreEntry.Clicked(gtx) { u.runAction("restore entry", u.restoreSelectedRecycleEntryAction) } for u.saveTemplate.Clicked(gtx) { u.runAction("save template", u.saveTemplateAction) } for u.deleteTemplate.Clicked(gtx) { u.runAction("delete template", u.deleteSelectedTemplateAction) } for u.instantiateTemplate.Clicked(gtx) { u.runAction("instantiate template", u.instantiateSelectedTemplateAction) } for u.addAttachment.Clicked(gtx) { u.runAction("add attachment", u.addAttachmentAction) } for u.removeAttachment.Clicked(gtx) { u.runAction("remove attachment", u.removeAttachmentAction) } for u.exportAttachment.Clicked(gtx) { u.runAction("export attachment", u.exportAttachmentAction) } for u.copyUser.Clicked(gtx) { u.runAction("copy username", func() error { return u.copySelectedFieldAction(clipboard.TargetUsername) }) } for u.copyPass.Clicked(gtx) { u.runAction("copy password", func() error { return u.copySelectedFieldAction(clipboard.TargetPassword) }) } for u.copyURL.Clicked(gtx) { u.runAction("copy URL", func() error { return u.copySelectedFieldAction(clipboard.TargetURL) }) } for u.generatePassword.Clicked(gtx) { u.runAction("generate password", u.generatePasswordAction) } for u.restoreHistory.Clicked(gtx) { u.runAction("restore history", u.restoreSelectedHistoryAction) } for u.createGroup.Clicked(gtx) { u.runAction("create group", u.createGroupAction) } for u.renameGroup.Clicked(gtx) { u.runAction("rename group", u.renameGroupAction) } for u.deleteGroup.Clicked(gtx) { u.runAction("delete group", u.deleteCurrentGroupAction) } for u.togglePassword.Clicked(gtx) { u.showPassword = !u.showPassword } for u.togglePasswordInline.Clicked(gtx) { u.showPassword = !u.showPassword } if _, changed := u.search.Update(gtx); changed { u.filter() } inset := layout.UniformInset(unit.Dp(16)) return layout.Background{}.Layout(gtx, fill(bgColor), func(gtx layout.Context) layout.Dimensions { return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(u.header), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { if u.mode == "phone" || gtx.Constraints.Max.X < gtx.Dp(unit.Dp(720)) { u.phoneSpan = gtx.Constraints.Max.Y listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value) if listHeight < gtx.Dp(unit.Dp(180)) { listHeight = gtx.Dp(unit.Dp(180)) } if listHeight > gtx.Constraints.Max.Y-gtx.Dp(unit.Dp(220)) { listHeight = gtx.Constraints.Max.Y - gtx.Dp(unit.Dp(220)) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { gtx.Constraints.Min.Y = listHeight gtx.Constraints.Max.Y = listHeight return u.listPanel(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(u.phoneSlider), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Flexed(1, u.detailPanel), ) } return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, layout.Flexed(0.38, u.listPanel), layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout), layout.Flexed(0.62, u.detailPanel), ) }), ) }) }) } func (u *ui) header(gtx layout.Context) layout.Dimensions { if u.mode == "phone" { 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 { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(20), "Vault") lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.lockVault, "Lock") return btn.Layout(gtx) }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(u.lifecycleControls), ) }) } return card(gtx, 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{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(24), "Vault") lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(13), "A single app concept for desktop and Android") lbl.Color = mutedColor return lbl.Layout(gtx) }), ) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.lockVault, "Lock") return btn.Layout(gtx) }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(u.lifecycleControls), ) }) } func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { panel := card spacing := unit.Dp(12) if u.mode == "phone" { panel = compactCard spacing = unit.Dp(8) } u.ensureNavClickables() return panel(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(u.sectionBar), layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Rigid(u.pathBar), layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Rigid(u.groupBar), layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Rigid(u.groupControls), layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.mode == "phone" { gtx.Constraints.Min.X = gtx.Constraints.Max.X } return outlinedField(gtx, func(gtx layout.Context) layout.Dimensions { editor := material.Editor(u.theme, &u.search, "Search vault") editor.Color = u.theme.Palette.Fg editor.HintColor = mutedColor return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout) }) }), layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { label := "Add Entry" if u.mode == "phone" { label = "+ Add Entry" } btn := material.Button(u.theme, &u.addEntry, label) return btn.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { if len(u.visible) == 0 { lbl := material.Label(u.theme, unit.Sp(16), "No entries match.") lbl.Color = mutedColor return lbl.Layout(gtx) } 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) sectionBar(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.showEntries, "Entries") }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showTemplates, "Templates") }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin") }), ) } func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item entry) layout.Dimensions { for click.Clicked(gtx) { _ = u.state.ToggleVisibleIndex(idx) u.loadSelectedEntryIntoEditor() } return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions { inset := unit.Dp(12) titleSize := unit.Sp(18) metaSize := unit.Sp(14) urlSize := unit.Sp(13) if u.mode == "phone" { inset = unit.Dp(10) titleSize = unit.Sp(16) metaSize = unit.Sp(13) urlSize = unit.Sp(12) } row := func(gtx layout.Context) layout.Dimensions { 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 = 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, metaSize, item.Username) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, urlSize, item.URL) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if strings.TrimSpace(u.search.Text()) == "" { return layout.Dimensions{} } lbl := material.Label(u.theme, unit.Sp(11), strings.Join(item.Path, " / ")) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { w := gtx.Constraints.Max.X if w < 1 { w = 1 } paint.FillShape(gtx.Ops, color.NRGBA{R: 232, G: 227, B: 219, A: 255}, clip.Rect{Max: image.Pt(w, 1)}.Op()) return layout.Dimensions{Size: image.Pt(w, 1)} }), ) }) } if item.ID == u.state.SelectedEntryID { 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(func(gtx layout.Context) layout.Dimensions { return row(gtx) }), ) } return layout.Background{}.Layout(gtx, fill(panelColor), func(gtx layout.Context) layout.Dimensions { return row(gtx) }) }) } func (u *ui) phoneSlider(gtx layout.Context) layout.Dimensions { if u.mode != "phone" { 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(18)) gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(18)) 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(84)) handleH := gtx.Dp(unit.Dp(4)) 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+1), Max: image.Pt(gtx.Constraints.Min.X, y+2)}.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.mode == "phone" { panel = compactCard } return panel(gtx, func(gtx layout.Context) layout.Dimensions { item, ok := u.selectedEntry() if !ok { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(16), "Edit the current form to create or update an item") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(u.entryEditorPanel), ) } password := strings.Repeat("•", max(8, len(item.Password))) if u.showPassword { password = item.Password } titleSize := unit.Sp(26) titlePad := unit.Dp(10) sectionGap := unit.Dp(8) if u.mode == "phone" { titleSize = unit.Sp(18) titlePad = unit.Dp(6) sectionGap = unit.Dp(6) } rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, titleSize, item.Title) lbl.Color = accentColor return lbl.Layout(gtx) }, layout.Spacer{Height: titlePad}.Layout, detailLine(u.theme, "Path", strings.Join(item.Path, " / ")), layout.Spacer{Height: sectionGap}.Layout, detailLine(u.theme, "Username", item.Username), layout.Spacer{Height: sectionGap}.Layout, u.passwordLine("Password", password), layout.Spacer{Height: sectionGap}.Layout, detailLine(u.theme, "URL", item.URL), layout.Spacer{Height: sectionGap}.Layout, detailLine(u.theme, "Tags", strings.Join(item.Tags, ", ")), layout.Spacer{Height: unit.Dp(12)}.Layout, func(gtx layout.Context) layout.Dimensions { if u.mode == "phone" { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyUser, "Copy User") }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password") }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openURL, "Open URL") }), ) } return layout.Flex{}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.copyUser, "Copy User") return btn.Layout(gtx) }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.copyPass, "Copy Password") return btn.Layout(gtx) }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.Button(u.theme, &u.openURL, "Open URL") return btn.Layout(gtx) }), ) }, layout.Spacer{Height: unit.Dp(12)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Body1(u.theme, item.Notes) lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(12)}.Layout, u.entryEditorPanel, } 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) pathBar(gtx layout.Context) layout.Dimensions { if u.state.Section == appstate.SectionRecycleBin { lbl := material.Label(u.theme, unit.Sp(13), "Recycle Bin") lbl.Color = mutedColor return lbl.Layout(gtx) } crumbs := append([]string{"Vault"}, append([]string{}, u.currentPath...)...) if u.state.Section == appstate.SectionTemplates { crumbs = append([]string{"Templates"}, append([]string{}, u.currentPath...)...) } 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) { if index == 0 { u.currentPath = nil } else { u.currentPath = append([]string{}, crumbs[1:index+1]...) } u.filter() } btn := material.Button(u.theme, &u.breadcrumbs[index], label) btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255} btn.Color = accentColor btn.TextSize = unit.Sp(12) btn.Inset = layout.Inset{Top: 6, Bottom: 6, Left: 10, Right: 10} 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 return layout.UniformInset(unit.Dp(6)).Layout(gtx, lbl.Layout) })) } } return children }()...) } 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)) } if len(groups) == 0 { return layout.Dimensions{} } return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, func() []layout.FlexChild { children := make([]layout.FlexChild, 0, len(groups)) for i, group := range groups { idx := i name := group children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { for u.groupClicks[idx].Clicked(gtx) { u.currentPath = append(append([]string{}, u.currentPath...), name) u.filter() } btn := material.Button(u.theme, &u.groupClicks[idx], "Folder: "+name) btn.Background = color.NRGBA{R: 241, G: 236, B: 227, A: 255} btn.Color = accentColor btn.TextSize = unit.Sp(12) return btn.Layout(gtx) })) } return children }()...) } func detailLine(th *material.Theme, label, value 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(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, unit.Sp(16), value) return lbl.Layout(gtx) }), ) } } func (u *ui) passwordLine(label, value string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { icon := u.eyeIcon desc := "Show password" if u.showPassword { icon = u.eyeOffIcon desc = "Hide password" } 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, unit.Sp(16), value) return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { btn := material.IconButton(u.theme, &u.togglePasswordInline, 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 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 outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions { border := color.NRGBA{R: 202, G: 194, B: 180, A: 255} size := gtx.Constraints.Min if size.X == 0 { size.X = gtx.Constraints.Max.X } if size.Y == 0 { size.Y = gtx.Dp(unit.Dp(44)) } 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 { paint.FillShape(gtx.Ops, border, clip.Rect{Max: image.Pt(size.X, 1)}.Op()) paint.FillShape(gtx.Ops, border, clip.Rect{Min: image.Pt(0, size.Y-1), Max: image.Pt(size.X, size.Y)}.Op()) paint.FillShape(gtx.Ops, border, clip.Rect{Max: image.Pt(1, size.Y)}.Op()) paint.FillShape(gtx.Ops, border, 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 < gtx.Dp(unit.Dp(44)) { dims.Size.Y = gtx.Dp(unit.Dp(44)) } return dims }), ) } func tonedButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string) layout.Dimensions { btn := material.Button(th, click, label) btn.Background = color.NRGBA{R: 231, G: 239, B: 235, A: 255} btn.Color = accentColor btn.CornerRadius = unit.Dp(10) btn.TextSize = unit.Sp(15) 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} } } func main() { mode := flag.String("mode", "desktop", "window mode: desktop or phone") flag.Parse() width := unit.Dp(1180) height := unit.Dp(760) if strings.EqualFold(*mode, "phone") { // Pixel 10 uses a 20:9 display; use a 412x915 dp viewport as a desktop-friendly preview. width = unit.Dp(412) height = unit.Dp(915) } go func() { w := new(app.Window) w.Option( app.Title("Vault Mock"), app.Size(width, height), ) if err := run(w, strings.ToLower(*mode)); err != nil { panic(err) } os.Exit(0) }() app.Main() } func run(w *app.Window, mode string) error { var ops op.Ops ui := newUI(mode) for { e := w.Event() switch e := e.(type) { case app.DestroyEvent: return e.Err case app.FrameEvent: gtx := app.NewContext(&ops, e) ui.layout(gtx) e.Frame(gtx.Ops) } } } type uiSession struct { model vault.Model locked bool } func (s *uiSession) Current() (vault.Model, error) { if s.locked { return vault.Model{}, session.ErrLocked } return s.model, nil } func (s *uiSession) Replace(model vault.Model) { s.model = model } func (s *uiSession) Lock() error { s.locked = true return nil } func (s *uiSession) Unlock(vault.MasterKey) error { if !s.locked { return nil } s.locked = false return nil }