diff --git a/main.go b/main.go index 0bdb919..21ac8c1 100644 --- a/main.go +++ b/main.go @@ -219,6 +219,8 @@ type ui struct { exportAttachment widget.Clickable restoreHistory widget.Clickable generatePassword widget.Clickable + goToRootGroup widget.Clickable + goToParentGroup widget.Clickable createGroup widget.Clickable moveGroup widget.Clickable renameGroup widget.Clickable @@ -1506,6 +1508,29 @@ func (u *ui) displayEntryPath(path []string) []string { return append([]string(nil), path[1:]...) } +func (u *ui) currentGroupDisplayName() string { + displayPath := u.displayPath() + if len(displayPath) == 0 { + return "Vault root (/)" + } + return strings.Join(displayPath, " / ") +} + +func (u *ui) parentGroupDisplayName() string { + displayPath := u.displayPath() + if len(displayPath) <= 1 { + return "Vault root (/)" + } + return strings.Join(displayPath[:len(displayPath)-1], " / ") +} + +func (u *ui) createGroupLabel() string { + if len(u.displayPath()) == 0 { + return "Create Top-Level Group" + } + return "Create Subgroup" +} + func pathHasPrefix(path, prefix []string) bool { if len(prefix) > len(path) { return false @@ -3765,49 +3790,109 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions { if len(u.groupClicks) < len(groups) { u.groupClicks = make([]widget.Clickable, len(groups)) } - if len(groups) == 0 { - return layout.Dimensions{} - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild { - children := []layout.FlexChild{ + displayPath := u.displayPath() + atRoot := len(displayPath) == 0 + 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 { - label := "Groups" - if len(u.displayPath()) > 0 { - label = "Subgroups" + lbl := material.Label(u.theme, unit.Sp(12), "BROWSE GROUPS") + 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(12), "Root anchors the vault, current group sets the listing, and child groups open deeper paths.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(detailLine(u.theme, "Root", "Vault root (/)")), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(detailLine(u.theme, "Current Group", u.currentGroupDisplayName())), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if atRoot { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(detailLine(u.theme, "Parent Group", u.parentGroupDisplayName())), + ) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if atRoot { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + 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 { + for u.goToRootGroup.Clicked(gtx) { + root := u.hiddenVaultRoot() + if root == "" { + u.setCurrentPath(nil) + } else { + u.setCurrentPath([]string{root}) + } + u.filter() + } + return tonedButton(gtx, u.theme, &u.goToRootGroup, "Back to Root") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + for u.goToParentGroup.Clicked(gtx) { + u.setCurrentPath(u.currentPath[:len(u.currentPath)-1]) + u.filter() + } + return tonedButton(gtx, u.theme, &u.goToParentGroup, "Up One Group") + }), + ) + }), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + label := "CHILD GROUPS" + if atRoot { + label = "TOP-LEVEL GROUPS" } lbl := material.Label(u.theme, unit.Sp(12), label) lbl.Color = mutedColor return lbl.Layout(gtx) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - } - children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - maxGroupListHeight := 200 - if u.mode == "phone" { - maxGroupListHeight = 112 - } - 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.currentPath = append([]string(nil), u.state.CurrentPath...) - u.filter() - } - return tonedButton(gtx, u.theme, &u.groupClicks[idx], name) + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if len(groups) == 0 { + lbl := material.Label(u.theme, unit.Sp(12), "No child groups here yet.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + } + maxGroupListHeight := 200 + if u.mode == "phone" { + maxGroupListHeight = 112 + } + 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.currentPath = append([]string(nil), u.state.CurrentPath...) + u.filter() + } + return tonedButton(gtx, u.theme, &u.groupClicks[idx], "Open "+name) + }) }) - }) - })) - return children - }()...) + }), + ) + }) } func detailLine(th *material.Theme, label, value string) layout.Widget { diff --git a/main_test.go b/main_test.go index a16d668..425e841 100644 --- a/main_test.go +++ b/main_test.go @@ -1420,6 +1420,37 @@ func TestUIGroupControlsCanBeCollapsed(t *testing.T) { } } +func TestUIGroupNavigationLabelsDistinguishRootCurrentAndParent(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Groups: [][]string{{"Root"}, {"Root", "Infrastructure"}, {"Root", "Infrastructure", "Prod"}}, + }) + u.showEntriesSection() + + if got := u.currentGroupDisplayName(); got != "Vault root (/)" { + t.Fatalf("currentGroupDisplayName() at root = %q, want %q", got, "Vault root (/)") + } + if got := u.parentGroupDisplayName(); got != "Vault root (/)" { + t.Fatalf("parentGroupDisplayName() at root = %q, want %q", got, "Vault root (/)") + } + if got := u.createGroupLabel(); got != "Create Top-Level Group" { + t.Fatalf("createGroupLabel() at root = %q, want %q", got, "Create Top-Level Group") + } + + u.setCurrentPath([]string{"Root", "Infrastructure", "Prod"}) + + if got := u.currentGroupDisplayName(); got != "Infrastructure / Prod" { + t.Fatalf("currentGroupDisplayName() = %q, want %q", got, "Infrastructure / Prod") + } + if got := u.parentGroupDisplayName(); got != "Infrastructure" { + t.Fatalf("parentGroupDisplayName() = %q, want %q", got, "Infrastructure") + } + if got := u.createGroupLabel(); got != "Create Subgroup" { + t.Fatalf("createGroupLabel() = %q, want %q", got, "Create Subgroup") + } +} + func TestUIParentGroupShowsDescendantEntries(t *testing.T) { t.Parallel() @@ -2965,6 +2996,7 @@ func TestUILoadingDetailMessageUsesSelectedVault(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) + u.lifecycleMode = "local" u.vaultPath.SetText("/home/julian/vaults/main.kdbx") u.loadingMessage = "Open vault..." diff --git a/ui_forms.go b/ui_forms.go index eff6e2f..0832631 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -593,16 +593,38 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions { } deletable, deleteReason := u.currentGroupDeletionState() return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "GROUP MANAGEMENT") + 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(12), "Create, rename, move, and delete groups without interrupting the browsing path above.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "CREATE") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + help := "Add a new top-level group." + if len(u.displayPath()) > 0 { + help = "Add a subgroup inside " + u.currentGroupDisplayName() + "." + } + lbl := material.Label(u.theme, unit.Sp(12), help) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(labeledEditor(u.theme, "Create Group / Subgroup", &u.groupName, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.createGroup, "Create Group") + return tonedButton(gtx, u.theme, &u.createGroup, u.createGroupLabel()) }), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -621,6 +643,12 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions { 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(u.theme, unit.Sp(12), "Rename or relocate only the selected group. Browsing stays in the separate group navigator.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.renameGroup, "Rename Current Group") @@ -726,7 +754,7 @@ func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "Group tools") + lbl := material.Label(u.theme, unit.Sp(12), "Group management") lbl.Color = mutedColor return lbl.Layout(gtx) }),