Make the screen explain itself: labels, tools, help pages, hints

For developers new to submodules (notes/usability-plan.md, D32):
- x and X read "restore to pin" and "restore committed pins"
- problems only, filter and fetch are shown in the footer
- ? shows every key in titled columns, then a legend of the status
  words, symbols and spine roles; esc closes it
- u reads "pull (fast-forward)" on the superproject
- the detail pane suggests f when the last fetch is old
- a hint above the footer until the first key
- the header says "staged", like the tree

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 23:26:32 +02:00
co-authored by Claude Opus 5.5
parent b205576759
commit c9932932bb
19 changed files with 816 additions and 78 deletions
+24 -6
View File
@@ -60,6 +60,7 @@ type Model struct {
picker *branches.Model // the branch picker, nil if closed
pickerPath string // the repository the picker is for
notice string // an error to show above the footer until the next key
hint bool // the hint for newcomers above the footer, until the first key
checkUpdate func(context.Context) string // looks for a newer release, nil if off
update string // the newer release found, "" if none
@@ -104,6 +105,7 @@ func New(ctx context.Context, p submodule.Provider, opts ...Option) Model {
footer: footer.New(t, k),
spinner: sp,
filter: fi,
hint: true,
}
m.fetcher, _ = p.(submodule.Fetcher)
m.watcher, _ = p.(submodule.Watcher)
@@ -385,14 +387,17 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.answerRestore(msg)
}
}
if m.notice != "" {
m.notice = ""
if m.notice != "" || m.hint {
m.notice, m.hint = "", false
m.layout()
}
if m.filter.Focused() {
return m.typeFilter(msg)
}
switch {
case m.footer.HelpOpen() && msg.Type == tea.KeyEsc: // before the filter's esc
m.footer.CloseHelp()
m.layout()
case m.filtering && msg.Type == tea.KeyEsc:
m.closeFilter()
case key.Matches(msg, k.Quit):
@@ -401,7 +406,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.updateTo = m.update
return m, tea.Quit
case key.Matches(msg, k.Help):
m.footer.ToggleHelp()
m.footer.CycleHelp()
m.layout()
case key.Matches(msg, k.Up):
m.tree.Up()
@@ -517,10 +522,16 @@ func (m Model) filterView() string {
}
// extraLine reports whether a line above the footer is shown: a restore in
// progress, a failure, the filter, or the notice that only problems are
// shown.
// progress, a failure, the filter, the notice that only problems are shown,
// or the hint for newcomers.
func (m Model) extraLine() bool {
return m.filtering || m.tree.ProblemsOnly() || m.restore != nil || m.notice != ""
return m.filtering || m.tree.ProblemsOnly() || m.restore != nil || m.notice != "" || m.hint
}
// hintView is the line above the footer until the first key: where help is.
func (m Model) hintView() string {
muted := theme.Fg(m.theme.Muted)
return m.line(muted.Render("new here? ")+theme.Fg(m.theme.Text).Bold(true).Render("?")+muted.Render(" shows every key and what the words mean"), "")
}
// problemsView is the line above the footer while only problems are shown, so
@@ -581,6 +592,11 @@ func (m Model) View() string {
return theme.Fg(m.theme.Danger).Render("error: " + m.err.Error())
}
// the keys as they read for the selection; m is a copy
k := m.keys.forSelection(m.tree.RootSelected())
m.footer.SetKeys(k)
m.detail.SetActions(k.Submodule())
body := m.tree.View()
if m.showDetail {
pane := lipgloss.NewStyle().
@@ -601,6 +617,8 @@ func (m Model) View() string {
parts = append(parts, m.filterView())
} else if m.tree.ProblemsOnly() {
parts = append(parts, m.problemsView())
} else if m.hint {
parts = append(parts, m.hintView())
}
view := zone.Scan(lipgloss.JoinVertical(lipgloss.Left, append(parts, m.footer.View())...))
if m.picker != nil { // after zone.Scan, so no zone marker is cut
+17 -3
View File
@@ -6,6 +6,7 @@ import (
"path"
"slices"
"strings"
"time"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
@@ -18,6 +19,9 @@ import (
const labelWidth = 11
// staleFetch is the age of the last fetch from which the pane suggests f.
const staleFetch = 24 * time.Hour
type Model struct {
theme theme.Theme
width int
@@ -38,6 +42,9 @@ func New(t theme.Theme, actions []key.Binding) Model {
// PaletteVisible reports whether the palette of submodule actions fits.
func (m Model) PaletteVisible() bool { return m.height >= paletteMinHeight }
// SetActions replaces the bindings of the palette.
func (m *Model) SetActions(actions []key.Binding) { m.actions = actions }
func (m *Model) SetSize(w, h int) { m.width, m.height = w, h }
func (m *Model) SetSpinner(frame string) { m.spinner = frame }
@@ -123,10 +130,17 @@ func (m Model) lines(s *submodule.Submodule, repo *submodule.Repo) []string {
st := s.Status
// the age of the last fetch is right-aligned in the name's line
// with a hint to fetch if the last fetch is old, and room for it
if st.Presence == submodule.PresenceOK {
fetched := "fetched " + theme.Ago(st.FetchedAt)
if gap := m.width - ansi.StringWidth(s.Name) - len(fetched); gap >= 2 {
out[1] += strings.Repeat(" ", gap) + muted.Render(fetched)
fetched := muted.Render("fetched " + theme.Ago(st.FetchedAt))
if time.Since(st.FetchedAt) > staleFetch {
hinted := fetched + muted.Render(" · ") + text.Bold(true).Render("f") + muted.Render(" fetch")
if m.width-ansi.StringWidth(s.Name)-lipgloss.Width(hinted) >= 2 {
fetched = hinted
}
}
if gap := m.width - ansi.StringWidth(s.Name) - lipgloss.Width(fetched); gap >= 2 {
out[1] += strings.Repeat(" ", gap) + fetched
}
}
+43
View File
@@ -0,0 +1,43 @@
package detail
import (
"strings"
"testing"
"time"
"github.com/charmbracelet/x/ansi"
"submarine/internal/submodule"
"submarine/internal/tui/theme"
)
func TestFetchHintForOldFetches(t *testing.T) {
m := New(theme.Default(), nil)
m.SetSize(60, 20)
for _, c := range []struct {
fetched time.Time
hint bool
}{
{time.Time{}, true},
{time.Now().Add(-48 * time.Hour), true},
{time.Now().Add(-time.Hour), false},
} {
s := &submodule.Submodule{Path: "src/app", Name: "app", Status: &submodule.Status{
Presence: submodule.PresenceOK, FetchedAt: c.fetched, Pin: submodule.PinInfo{Pushed: true},
}}
got := ansi.Strip(m.View(s, nil))
if strings.Contains(got, "f fetch") != c.hint {
t.Errorf("fetched %s: hint %v, want %v:\n%s", theme.Ago(c.fetched), !c.hint, c.hint, got)
}
if !strings.Contains(got, "fetched "+theme.Ago(c.fetched)) {
t.Errorf("the age is missing:\n%s", got)
}
}
// too narrow for the hint: the age alone
m.SetSize(24, 20)
s := &submodule.Submodule{Path: "src/app", Name: "app", Status: &submodule.Status{Presence: submodule.PresenceOK}}
if got := ansi.Strip(m.View(s, nil)); strings.Contains(got, "f fetch") || !strings.Contains(got, "fetched never") {
t.Errorf("narrow pane:\n%s", got)
}
}
+2 -2
View File
@@ -15,7 +15,7 @@ func TestPaletteWrapsInsteadOfCuttingKeys(t *testing.T) {
key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")),
key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "update pin")),
key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "switch branch")),
key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore")),
key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore to pin")),
}
m := New(theme.Default(), keys)
m.SetSize(36, 20)
@@ -24,7 +24,7 @@ func TestPaletteWrapsInsteadOfCuttingKeys(t *testing.T) {
t.Fatalf("want 2 lines, got %q", lines)
}
all := strings.Join(lines, "\n")
for _, want := range []string{"copy path", "update pin", "switch branch", "x restore"} {
for _, want := range []string{"copy path", "update pin", "switch branch", "x restore to pin"} {
if !strings.Contains(all, want) {
t.Errorf("palette lacks %q: %q", want, lines)
}
+132 -11
View File
@@ -2,6 +2,7 @@
package footer
import (
"slices"
"strings"
"time"
@@ -18,18 +19,39 @@ const flashDuration = 2 * time.Second
type clearFlashMsg struct{ id int }
// Keys provides the key groups. The short bar shows Navigation (and
// Keys provides the key groups. The short bar shows Navigation, Tools (and
// Submodule, if requested) on the left, Global and then Highlight (on a
// highlighted background) on the right; full help
// shows FullHelp.
// highlighted background) on the right. The full help has two pages: the
// HelpGroups (the last one is always kept), then the theme's legend with
// LegendKeys next to it.
type Keys interface {
help.KeyMap
Navigation() []key.Binding
Tools() []key.Binding
Submodule() []key.Binding
Global() []key.Binding
Highlight() []key.Binding
HelpGroups() []Group
LegendKeys() Group
}
// Group is a titled column of the full help.
type Group struct {
Title string
Keys []key.Binding
}
// helpPage is what the footer shows besides a flash.
type helpPage int
const (
pageBar helpPage = iota // one line of key hints
pageKeys // every key, in titled columns
pageLegend // what the words and symbols mean
)
// columnGap separates the columns of the full help.
const columnGap = " "
type Model struct {
theme theme.Theme
help help.Model
@@ -37,6 +59,7 @@ type Model struct {
width int
flash string
flashID int
page helpPage
actions bool // show the submodule bindings in the short bar
}
@@ -56,8 +79,18 @@ func (m *Model) SetWidth(w int) {
m.help.Width = w - 2
}
// ToggleHelp switches between short and full key help.
func (m *Model) ToggleHelp() { m.help.ShowAll = !m.help.ShowAll }
// CycleHelp shows the next help page: the short bar, every key, the legend,
// then the short bar again.
func (m *Model) CycleHelp() { m.page = (m.page + 1) % 3 }
// HelpOpen reports whether a full help page is shown.
func (m Model) HelpOpen() bool { return m.page != pageBar }
// CloseHelp goes back to the short bar from any help page.
func (m *Model) CloseHelp() { m.page = pageBar }
// Legend reports whether the legend is shown.
func (m Model) Legend() bool { return m.page == pageLegend }
// Flash shows msg for a short time instead of the key hints.
func (m *Model) Flash(msg string) tea.Cmd {
@@ -78,14 +111,102 @@ func (m Model) View() string {
if m.flash != "" {
return style.Foreground(m.theme.Warn).Render(m.flash)
}
if m.help.ShowAll {
return style.Render(m.help.View(m.keys))
switch m.page {
case pageKeys:
var cols []column
for _, g := range m.keys.HelpGroups() {
cols = append(cols, m.keyColumn(g))
}
return style.Render(m.columns(cols[:len(cols)-1], cols[len(cols)-1], m.width-2))
case pageLegend:
var cols []column
for _, g := range m.theme.Legend() {
c := column{title: g.Title}
for _, r := range g.Rows {
c.rows = append(c.rows, [2]string{r.Word, m.help.Styles.FullDesc.Render(r.Meaning)})
}
cols = append(cols, c)
}
return style.Render(m.columns(cols, m.keyColumn(m.keys.LegendKeys()), m.width-2))
}
return style.Render(m.shortBar(m.width - 2))
}
// column is a titled column of the full help: rows of a rendered key (or
// word) and its description.
type column struct {
title string
rows [][2]string
}
func (m Model) keyColumn(g Group) column {
c := column{title: g.Title}
for _, b := range g.Keys {
if b.Enabled() {
c.rows = append(c.rows, [2]string{m.help.Styles.FullKey.Render(b.Help().Key), m.help.Styles.FullDesc.Render(b.Help().Desc)})
}
}
return c
}
// columns lays cols out side by side, each under its title, and last at the
// end. Like the global keys in the short bar, last is always kept: the
// columns before it that don't fit into w are left out from the right,
// marked with an ellipsis.
func (m Model) columns(cols []column, last column, w int) string {
tail := m.block(last)
avail := w - lipgloss.Width(tail) - len(columnGap)
var out []string
width := 0
for i, c := range cols {
b := m.block(c)
bw := lipgloss.Width(b) + len(columnGap)
if i == 0 {
bw -= len(columnGap)
}
if width+bw > avail {
for len(out) > 0 && width+2 > avail { // room for the ellipsis
width -= lipgloss.Width(out[len(out)-1]) + len(columnGap)
out = out[:len(out)-1]
}
if width+2 <= avail {
out = append(out, " "+m.help.Styles.Ellipsis.Render("…"))
}
break
}
out = append(out, b)
width += bw
}
var parts []string
for i, b := range out {
if i > 0 && !strings.HasPrefix(b, " ") {
parts = append(parts, columnGap)
}
parts = append(parts, b)
}
if len(parts) > 0 {
parts = append(parts, columnGap)
}
parts = append(parts, tail)
return lipgloss.NewStyle().MaxWidth(w).Render(lipgloss.JoinHorizontal(lipgloss.Top, parts...))
}
// block renders a column: its title, then its rows with the descriptions
// aligned.
func (m Model) block(c column) string {
left := 0
for _, r := range c.rows {
left = max(left, lipgloss.Width(r[0]))
}
lines := []string{theme.Fg(m.theme.Muted).Bold(true).Render(c.title)}
for _, r := range c.rows {
lines = append(lines, r[0]+strings.Repeat(" ", left-lipgloss.Width(r[0])+1)+r[1])
}
return strings.Join(lines, "\n")
}
// shortBar puts the global keys on the right and as many of the others on the
// left as fit; the submodule keys are dropped first.
// left as fit; the submodule keys are dropped first, then the tools.
func (m Model) shortBar(w int) string {
h := m.help
h.Width = 0
@@ -97,9 +218,9 @@ func (m Model) shortBar(w int) string {
}
right += " " + m.theme.Highlight().Render(" "+strings.Join(parts, " ")+" ")
}
left := m.keys.Navigation()
left := append(slices.Clip(m.keys.Navigation()), m.keys.Tools()...)
if m.actions {
left = append(left[:len(left):len(left)], m.keys.Submodule()...)
left = append(left, m.keys.Submodule()...)
}
for ; ; left = left[:len(left)-1] {
l := h.ShortHelpView(left)
+88 -3
View File
@@ -15,14 +15,25 @@ type testKeys struct{ highlight []key.Binding }
func bind(k, desc string) key.Binding { return key.NewBinding(key.WithKeys(k), key.WithHelp(k, desc)) }
func (testKeys) ShortHelp() []key.Binding { return nil }
func (testKeys) FullHelp() [][]key.Binding { return nil }
func (testKeys) Navigation() []key.Binding {
return []key.Binding{bind("j", "move"), bind("h", "fold")}
}
func (testKeys) Submodule() []key.Binding { return nil }
func (testKeys) Tools() []key.Binding {
return []key.Binding{bind("!", "problems only"), bind("/", "filter"), bind("f", "fetch")}
}
func (testKeys) Submodule() []key.Binding { return []key.Binding{bind("x", "restore to pin")} }
func (testKeys) Global() []key.Binding { return []key.Binding{bind("?", "help"), bind("Q", "quit")} }
func (k testKeys) Highlight() []key.Binding { return k.highlight }
func (testKeys) HelpGroups() []Group {
return []Group{
{Title: "Move", Keys: []key.Binding{bind("j", "down"), bind("k", "up")}},
{Title: "Selected submodule", Keys: []key.Binding{bind("x", "restore to pin")}},
{Title: "App", Keys: []key.Binding{bind("?", "what words mean"), bind("Q", "quit")}},
}
}
func (testKeys) LegendKeys() Group {
return Group{Title: "App", Keys: []key.Binding{bind("esc", "close")}}
}
func bar(w int, hl ...key.Binding) string {
m := New(theme.Default(), testKeys{hl})
@@ -52,3 +63,77 @@ func TestNavigationGivesWayToUpdate(t *testing.T) {
t.Errorf("the right group must be kept:\n%q", got)
}
}
func TestToolsFollowNavigation(t *testing.T) {
got := bar(80)
if !strings.Contains(got, "h fold • ! problems only • / filter • f fetch") {
t.Errorf("tools must follow the navigation keys:\n%q", got)
}
}
func TestToolsGiveWayBeforeNavigation(t *testing.T) {
got := bar(40)
if strings.Contains(got, "f fetch") || !strings.Contains(got, "j move") {
t.Errorf("tools must go before navigation:\n%q", got)
}
}
func TestSubmoduleKeysGoFirst(t *testing.T) {
m := New(theme.Default(), testKeys{})
m.SetActions(true)
m.SetWidth(80)
got := ansi.Strip(m.View())
if strings.Contains(got, "restore to pin") || !strings.Contains(got, "f fetch") {
t.Errorf("submodule keys must go before the tools:\n%q", got)
}
m.SetWidth(120)
if got := ansi.Strip(m.View()); !strings.Contains(got, "f fetch • x restore to pin") {
t.Errorf("submodule keys follow the tools:\n%q", got)
}
}
func TestHelpPages(t *testing.T) {
m := New(theme.Default(), testKeys{})
m.SetWidth(120)
m.CycleHelp()
keys := ansi.Strip(m.View())
for _, want := range []string{"Move", "Selected submodule", "App", "x restore to pin", "? what words mean"} {
if !strings.Contains(keys, want) {
t.Errorf("key page lacks %q:\n%s", want, keys)
}
}
m.CycleHelp()
if !m.Legend() {
t.Fatal("second page must be the legend")
}
legend := ansi.Strip(m.View())
for _, want := range []string{"Status", "off pin", "Symbols", "Commits", "esc close"} {
if !strings.Contains(legend, want) {
t.Errorf("legend lacks %q:\n%s", want, legend)
}
}
for _, l := range strings.Split(m.View(), "\n") {
if w := lipgloss.Width(l); w > 120 {
t.Errorf("legend line is %d wide: %q", w, ansi.Strip(l))
}
}
m.CycleHelp()
if got := ansi.Strip(m.View()); !strings.Contains(got, "? help") {
t.Errorf("third ? must close the help:\n%q", got)
}
}
func TestHelpLeavesOutColumnsThatDontFit(t *testing.T) {
m := New(theme.Default(), testKeys{})
m.SetWidth(30)
m.CycleHelp()
got := ansi.Strip(m.View())
if !strings.Contains(got, "App") || strings.Contains(got, "Selected") || !strings.Contains(got, "…") {
t.Errorf("want App kept, the columns before it left out with an ellipsis:\n%s", got)
}
for _, l := range strings.Split(m.View(), "\n") {
if w := lipgloss.Width(l); w > 30 {
t.Errorf("line is %d wide: %q", w, ansi.Strip(l))
}
}
}
+2 -5
View File
@@ -72,16 +72,13 @@ func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, widt
if errored > 0 {
parts = append(parts, t.LevelStyle(submodule.LevelDanger, bar).Render(fmt.Sprintf("%d error", errored)))
}
for _, k := range []submodule.IssueKind{
submodule.IssueUnpushed, submodule.IssueConflict, submodule.IssueMissing,
submodule.IssueOffPin, submodule.IssueChanged, submodule.IssueOffTarget,
} {
for _, k := range submodule.Kinds {
if n := perKind[k]; n > 0 {
parts = append(parts, t.LevelStyle(level[k], bar).Render(fmt.Sprintf("%d %s", n, theme.IssueWord(k))))
}
}
if staged > 0 {
parts = append(parts, seg(t.Info, fmt.Sprintf("%d pin staged", staged)))
parts = append(parts, seg(t.Info, fmt.Sprintf("%d %s", staged, theme.Staged)))
}
if loading > 0 {
parts = append(parts, seg(t.Muted, fmt.Sprintf("%d loading", loading)))
+35 -14
View File
@@ -2,6 +2,8 @@ package tui
import (
"github.com/charmbracelet/bubbles/key"
"submarine/internal/tui/footer"
)
type keyMap struct {
@@ -31,8 +33,8 @@ func defaultKeys() keyMap {
Reload: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reload")),
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")),
Restore: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore")),
HardRestore: key.NewBinding(key.WithKeys("X"), key.WithHelp("X", "hard restore")),
Restore: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore to pin")),
HardRestore: key.NewBinding(key.WithKeys("X"), key.WithHelp("X", "restore committed pins")),
Switch: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "switch branch")),
Update: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "update pin")),
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")),
@@ -52,11 +54,16 @@ func (k *keyMap) SetUpdate(v string) {
// Navigation returns the bindings that move the selection or fold the tree.
func (k keyMap) Navigation() []key.Binding {
move := key.NewBinding(key.WithKeys("up"), key.WithHelp("↑↓/jk", "move"))
fold := key.NewBinding(key.WithKeys("left"), key.WithHelp("←→/hl", "fold"))
all := key.NewBinding(key.WithKeys("H"), key.WithHelp("⇧←→/HL", "fold all"))
fold := key.NewBinding(key.WithKeys("left"), key.WithHelp("←→/hl", "collapse/expand"))
all := key.NewBinding(key.WithKeys("H"), key.WithHelp("⇧←→/HL", "all"))
return []key.Binding{move, fold, all, k.Toggle}
}
// Tools returns the bindings that act on the whole list: problems only,
// filter and fetch, most useful first, since the footer drops them from the
// end. It shows them after the navigation keys.
func (k keyMap) Tools() []key.Binding { return []key.Binding{k.Issues, k.Filter, k.Fetch} }
// Submodule returns the bindings that act on the selected submodule. They
// are shown in the detail pane, or in the footer if the pane can't show them.
func (k keyMap) Submodule() []key.Binding {
@@ -83,17 +90,31 @@ func (k keyMap) Highlight() []key.Binding {
return []key.Binding{k.QuitUpdate}
}
func (k keyMap) ShortHelp() []key.Binding {
return append(append(k.Navigation(), k.Submodule()...), k.Global()...)
// forSelection returns the keys as they read for the selection: on the
// superproject, u fast-forwards its branch (D29).
func (k keyMap) forSelection(root bool) keyMap {
if root {
k.Update.SetHelp("u", "pull (fast-forward)")
}
return k
}
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down, k.Left, k.Right, k.Toggle},
{k.CollapseAll, k.ExpandAll},
{k.Home, k.End, k.PageUp, k.PageDown},
{k.Copy, k.Update, k.Switch, k.Restore, k.HardRestore},
{k.Fetch, k.Reload, k.Filter, k.Issues},
append([]key.Binding{k.Help, k.Quit}, k.fullUpdate()...),
// HelpGroups returns the columns of the full help, each with a title.
func (k keyMap) HelpGroups() []footer.Group {
help := key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "what words mean"))
esc := key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close"))
return []footer.Group{
{Title: "Move", Keys: []key.Binding{k.Up, k.Down, k.Left, k.Right, k.Toggle, k.CollapseAll, k.ExpandAll}},
{Title: "Jump", Keys: []key.Binding{k.Home, k.End, k.PageUp, k.PageDown}},
{Title: "Selected submodule", Keys: k.Submodule()},
{Title: "All submodules", Keys: []key.Binding{k.Fetch, k.Reload, k.Filter, k.Issues}},
{Title: "App", Keys: append([]key.Binding{help, esc, k.Quit}, k.fullUpdate()...)},
}
}
// LegendKeys returns the column next to the legend, the help's last page;
// ? closes it too, but esc is the key to tell.
func (k keyMap) LegendKeys() footer.Group {
esc := key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close"))
return footer.Group{Title: "App", Keys: append([]key.Binding{esc, k.Quit}, k.fullUpdate()...)}
}
+16
View File
@@ -0,0 +1,16 @@
package tui
import "testing"
func TestUpdateReadsPullOnTheSuperproject(t *testing.T) {
k := defaultKeys()
if got := k.forSelection(true).Update.Help().Desc; got != "pull (fast-forward)" {
t.Errorf("superproject: u reads %q", got)
}
if got := k.forSelection(false).Update.Help().Desc; got != "update pin" {
t.Errorf("submodule: u reads %q", got)
}
if got := k.Update.Help().Desc; got != "update pin" {
t.Errorf("forSelection changed the key map: u reads %q", got)
}
}
+62
View File
@@ -0,0 +1,62 @@
package theme
import "submarine/internal/submodule"
// LegendRow is one word or symbol of the UI, rendered as it appears, and
// what it means.
type LegendRow struct{ Word, Meaning string }
// LegendGroup is a titled column of the legend.
type LegendGroup struct {
Title string
Rows []LegendRow
}
// issueMeaning explains an issue word in a few plain words.
func issueMeaning(k submodule.IssueKind) string {
switch k {
case submodule.IssueUnpushed:
return "pin is on no remote"
case submodule.IssueConflict:
return "merge conflict"
case submodule.IssueMissing:
return "not checked out"
case submodule.IssueOffPin:
return "checkout is not the pin"
case submodule.IssueChanged:
return "uncommitted files"
default:
return "pin not on target branch"
}
}
// Legend explains the words and symbols of the tree and the detail pane, in
// their colors: what the full help's second page shows.
func (t Theme) Legend() []LegendGroup {
g := t.Glyphs
muted := Fg(t.Muted)
status := LegendGroup{Title: "Status"}
for _, k := range submodule.Kinds {
status.Rows = append(status.Rows, LegendRow{t.LevelStyle(k.Level(), Fg(t.Text)).Render(IssueWord(k)), issueMeaning(k)})
}
status.Rows = append(status.Rows,
LegendRow{Fg(t.Info).Render(Staged), "new pin, not committed"},
LegendRow{muted.Render(InSync), "nothing to do"},
)
symbols := LegendGroup{Title: "Symbols", Rows: []LegendRow{
{Fg(t.Info).Render(g.Staged), "pin staged in parent"},
{muted.Render(g.Ahead + "n " + g.Behind + "n"), "ahead / behind the ref"},
{muted.Render("+n"), "n more issues"},
{Fg(t.Warn).Render(g.Problems + "k"), "issues below, collapsed"},
{muted.Render("[n]"), "children, collapsed"},
}}
commits := LegendGroup{Title: "Commits", Rows: []LegendRow{
{muted.Render(RoleWord(submodule.RolePin, "")), "what the parent records"},
{Fg(t.Text).Bold(true).Render(RoleWord(submodule.RoleYou, "")), "the checkout"},
{muted.Render(RoleWord(submodule.RoleTip, "")), "newest on the ref"},
{muted.Render("main"), "tip of target branch"},
{muted.Render(RoleWord(submodule.RoleBase, "")), "where lines split"},
{muted.Render(RoleWord(submodule.RoleHead, "")), "superproject checkout"},
}}
return []LegendGroup{status, symbols, commits}
}
+42
View File
@@ -66,3 +66,45 @@ func TestQuitAndUpdate(t *testing.T) {
t.Errorf("ctrl+c must quit without updating, got update to %q", m2.(Model).UpdateTo())
}
}
func TestHintUntilTheFirstKey(t *testing.T) {
m := newUpdateModel("")
if !m.(Model).extraLine() || !strings.Contains(m.View(), "new here?") {
t.Fatal("no hint on start")
}
m, _ = press(m, "j")
if m.(Model).extraLine() || strings.Contains(m.View(), "new here?") {
t.Error("the hint must go away with the first key")
}
}
func TestEscClosesTheHelp(t *testing.T) {
for _, presses := range []int{1, 2} { // the keys page, the legend
m := newUpdateModel("")
for range presses {
m, _ = press(m, "?")
}
if !m.(Model).footer.HelpOpen() {
t.Fatalf("%d × ?: no help", presses)
}
m, _ = pressKey(m, tea.KeyEsc)
if m.(Model).footer.HelpOpen() {
t.Errorf("%d × ?: esc must close the help", presses)
}
}
}
func TestEscClosesTheHelpBeforeTheFilter(t *testing.T) {
m := newUpdateModel("")
m, _ = press(m, "/")
m, _ = pressKey(m, tea.KeyEnter) // keys back to the tree, the filter stays
m, _ = press(m, "?")
m, _ = pressKey(m, tea.KeyEsc)
if mm := m.(Model); mm.footer.HelpOpen() || !mm.filtering {
t.Errorf("first esc: help open %v, filtering %v; want the help closed, the filter kept", mm.footer.HelpOpen(), mm.filtering)
}
m, _ = pressKey(m, tea.KeyEsc)
if m.(Model).filtering {
t.Error("second esc must close the filter")
}
}