Archived
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:
+24
-6
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()...)}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user