feat: a ? help overlay
The keys were only in the footer, and only for the screen you were on, in a terse form. '?' now opens an overlay listing every key that does something on the current screen with a one-line description, plus the keys that work everywhere. It is an overlay rather than a screen, so the screen underneath keeps its scroll and cursor and closing it returns exactly where the user was; '?' or esc closes it; ctrl+c still quits; and while it is open it takes every other key, so nothing fires by accident while somebody is reading it. The list comes from one function per screen returning key/description pairs, and the footer now renders its hints from the same list, so the two cannot drift apart. Conditional states are included: the browse filter, the journal action menu where enter means confirm, and the review screen when an album that is already in the library has taken k and r, leaving the arrow keys as the only way to scroll up. It fits the terminal and degrades sanely on a small one: it drops screen keys before global ones, and on a tiny terminal shows a single line saying how to close it. Closes 869e373
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
@@ -186,6 +186,10 @@ type Module struct {
ready bool
cancel context.CancelFunc
scroll int
+ // helpOpen is the '?' overlay. It is a flag rather than a Screen because
+ // the screen underneath keeps all of its state, so closing the overlay
+ // puts the user back exactly where they were.
+ helpOpen bool
}
type albumsMsg struct {
@@ -528,6 +532,19 @@ func (m Module) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if key == "ctrl+c" {
return m.quit()
}
+ // The overlay takes every key but the ones that close it, so nothing on
+ // the screen underneath fires by accident while somebody is reading it.
+ if m.helpOpen {
+ switch key {
+ case "?", "esc":
+ m.helpOpen = false
+ }
+ return m, nil
+ }
+ if key == "?" {
+ m.helpOpen = true
+ return m, nil
+ }
switch m.screen {
case ScreenBrowse:
@@ -940,11 +957,16 @@ func (m Module) render() string {
header := m.renderHeader()
footer := m.renderFooter()
- body := m.renderBody()
-
bodyH := m.body().H
- body = m.applyScroll(body, m.width, bodyH)
- body = clamp(fitHeight(body, bodyH), m.width, bodyH)
+
+ var body string
+ if m.helpOpen {
+ body = m.helpOverlay(bodyH)
+ } else {
+ body = m.renderBody()
+ body = m.applyScroll(body, m.width, bodyH)
+ body = clamp(fitHeight(body, bodyH), m.width, bodyH)
+ }
parts := []string{header}
if m.railShown() {
@@ -1013,53 +1035,282 @@ func (m Module) renderRail() string {
}
func (m Module) renderFooter() string {
- var hints []string
+ hints := m.footerHelp()
+ parts := make([]string, 0, len(hints))
+ for _, h := range hints {
+ parts = append(parts, m.st.HintKey.Render(h.key)+" "+m.st.HintDesc.Render(h.desc))
+ }
+ left := " " + strings.Join(parts, m.st.Dim.Render(" · "))
+ status := ""
+ if m.err != nil {
+ status = m.st.Error.Render(m.err.Error())
+ } else if m.status != "" {
+ status = m.st.Status.Render(m.status)
+ }
+ line := left
+ if status != "" {
+ gap := m.width - displayWidth(left) - displayWidth(status) - 2
+ if gap < 1 {
+ return clamp(left, m.width, 1)
+ }
+ line = left + strings.Repeat(" ", gap) + status + " "
+ }
+ return clamp(line, m.width, 1)
+}
+
+// helpKey is one row of the help overlay: a key, or a few keys that do the same
+// thing written as "a / b", and the one line that says what it does.
+type helpKey struct {
+ key string
+ desc string
+}
+
+// helpKeys is every key that does something on the screen that is open. The
+// overlay draws the whole list and the footer picks a few rows out of it, so
+// the two cannot end up describing the same key differently.
+//
+// Filtering, the journal action menu and the review screen's collision choices
+// are states of their screen rather than screens of their own, so their keys
+// are only here while those states are on.
+func (m Module) helpKeys() []helpKey {
switch m.screen {
case ScreenBrowse:
if m.filtering {
- hints = []string{"enter", "apply", "esc", "cancel"}
- } else {
- hints = []string{"↑↓", "move", "/", "filter", "enter", "review", "b", "file all", "n", "next that needs you", "J", "journal", "r", "refresh", "q", "quit"}
+ return []helpKey{
+ {"enter", "apply filter"},
+ {"esc", "cancel filter"},
+ {"backspace", "delete a character"},
+ }
+ }
+ return []helpKey{
+ {"↑↓ / k j", "move"},
+ {"g / home", "first"},
+ {"G / end", "last"},
+ {"/", "filter"},
+ {"enter", "review"},
+ {"b", "file all"},
+ {"n", "next that needs you"},
+ {"J", "journal"},
+ {"r", "refresh"},
+ {"q", "quit"},
}
case ScreenJournal:
if m.journalMenu >= 0 {
- hints = []string{"↑↓", "choose", "enter", "do it", "esc", "cancel"}
- } else {
- hints = []string{"↑↓", "move", "enter", "actions", "r", "refresh", "esc", "back"}
+ confirm := "do it"
+ if m.journalConfirm {
+ confirm = "confirm"
+ }
+ return []helpKey{
+ {"↑↓ / k j", "choose"},
+ {"enter", confirm},
+ {"esc / q", "close the menu"},
+ }
+ }
+ return []helpKey{
+ {"↑↓ / k j", "move"},
+ {"g / home", "first"},
+ {"G / end", "last"},
+ {"enter", "actions"},
+ {"r", "refresh"},
+ {"esc", "back"},
+ {"q", "quit"},
}
case ScreenReview:
if m.existing != nil && m.step == StepMetadata {
- hints = []string{"k", "keep both", "r", "replace it", "s", "skip"}
- } else {
- hints = []string{"↑↓", "scroll", "enter", "continue", "e", "edit album", "esc", "back", "q", "quit"}
+ // The collision choices take k and r before the scroll keys get a
+ // chance, so the arrow is the only way left to scroll up.
+ return []helpKey{
+ {"k", "keep both"},
+ {"r", "replace it"},
+ {"s", "skip this album"},
+ {"↑", "scroll up"},
+ {"↓ / j", "scroll down"},
+ {"pgup / pgdown", "scroll a page"},
+ {"g / home", "top"},
+ {"G", "bottom"},
+ {"enter", "continue"},
+ {"e", "edit album"},
+ {"esc", "back"},
+ {"q", "quit"},
+ }
+ }
+ return []helpKey{
+ {"↑↓ / k j", "scroll"},
+ {"pgup / pgdown", "scroll a page"},
+ {"g / home", "top"},
+ {"G", "bottom"},
+ {"enter", "continue"},
+ {"e", "edit album"},
+ {"esc", "back"},
+ {"q", "quit"},
}
case ScreenCoverPicker:
- hints = []string{"↑↓", "choose", "enter", "use this cover", "e", "edit album", "esc", "cancel"}
+ return []helpKey{
+ {"↑↓ / k j", "choose"},
+ {"enter", "use this cover"},
+ {"e", "edit album"},
+ {"esc", "cancel"},
+ {"q", "quit"},
+ }
case ScreenWorking:
- hints = []string{"q", "stop watching"}
+ return []helpKey{
+ {"↑ / k", "scroll up, stop following"},
+ {"↓ / j", "scroll down, follow"},
+ {"pgup / pgdown", "scroll a page"},
+ {"g / home", "top"},
+ {"esc / q", "stop watching"},
+ }
case ScreenDone:
- hints = []string{"enter", "file another", "q", "quit"}
+ return []helpKey{
+ {"↑↓ / k j", "scroll"},
+ {"pgup / pgdown", "scroll a page"},
+ {"enter / esc", "file another"},
+ {"q", "quit"},
+ }
}
- parts := make([]string, 0, len(hints)/2)
- for i := 0; i+1 < len(hints); i += 2 {
- parts = append(parts, m.st.HintKey.Render(hints[i])+" "+m.st.HintDesc.Render(hints[i+1]))
+ return nil
+}
+
+// helpToggle is the key that opens and closes the overlay. The footer says what
+// it does from outside the overlay; helpGlobal says what it does from inside.
+func helpToggle() helpKey {
+ return helpKey{"?", "help"}
+}
+
+// helpGlobal is what works on every screen whatever else is open.
+func helpGlobal() []helpKey {
+ return []helpKey{
+ {"? / esc", "close help"},
+ {"ctrl+c", "quit"},
}
- left := " " + strings.Join(parts, m.st.Dim.Render(" · "))
- status := ""
- if m.err != nil {
- status = m.st.Error.Render(m.err.Error())
- } else if m.status != "" {
- status = m.st.Status.Render(m.status)
+}
+
+// footerKeys names the rows from helpKeys that the footer has room for. The
+// overlay is the reference; the footer is the reminder.
+func (m Module) footerKeys() []string {
+ switch m.screen {
+ case ScreenBrowse:
+ if m.filtering {
+ return []string{"enter", "esc"}
+ }
+ return []string{"↑↓ / k j", "/", "enter", "b", "n", "J", "r", "q"}
+ case ScreenJournal:
+ if m.journalMenu >= 0 {
+ return []string{"↑↓ / k j", "enter", "esc / q"}
+ }
+ return []string{"↑↓ / k j", "enter", "r", "esc", "q"}
+ case ScreenReview:
+ if m.existing != nil && m.step == StepMetadata {
+ return []string{"k", "r", "s"}
+ }
+ return []string{"↑↓ / k j", "enter", "e", "esc", "q"}
+ case ScreenCoverPicker:
+ return []string{"↑↓ / k j", "enter", "e", "esc"}
+ case ScreenWorking:
+ return []string{"esc / q"}
+ case ScreenDone:
+ return []string{"enter / esc", "q"}
}
- line := left
- if status != "" {
- gap := m.width - displayWidth(left) - displayWidth(status) - 2
- if gap < 1 {
- return clamp(left, m.width, 1)
+ return nil
+}
+
+// footerHelp is the footer's half of the help. The descriptions come from
+// helpKeys, so the footer cannot claim something about a key that the overlay
+// does not. The toggle leads, because the footer is the only place the overlay
+// is advertised and a footer that runs long is cut off at the right.
+func (m Module) footerHelp() []helpKey {
+ keys := m.helpKeys()
+ hints := make([]helpKey, 0, len(keys)+1)
+ hints = append(hints, helpToggle())
+ for _, want := range m.footerKeys() {
+ for _, k := range keys {
+ if k.key == want {
+ hints = append(hints, k)
+ break
+ }
}
- line = left + strings.Repeat(" ", gap) + status + " "
}
- return clamp(line, m.width, 1)
+ return hints
+}
+
+// helpTitle says which screen the keys belong to, since the overlay covers the
+// header that would otherwise say it.
+func (m Module) helpTitle() string {
+ switch m.screen {
+ case ScreenBrowse:
+ return "Help · staging"
+ case ScreenJournal:
+ return "Help · journal"
+ case ScreenReview:
+ return "Help · review"
+ case ScreenCoverPicker:
+ return "Help · cover art"
+ case ScreenWorking:
+ return "Help · filing"
+ case ScreenDone:
+ return "Help · done"
+ }
+ return "Help"
+}
+
+// helpOverlay draws the keys for the screen that is open over the body it
+// covers. It is sized to that body, and a list too long for the terminal is cut
+// off rather than allowed to push the layout past the screen. The rows that
+// close the overlay and quit are the last to be dropped, because they are the
+// only way out of a list that does not fit.
+func (m Module) helpOverlay(height int) string {
+ screen := m.helpKeys()
+ globals := helpGlobal()
+
+ // The panel spends three lines on its borders and title before any key is
+ // drawn.
+ avail := height - 3
+ if avail < 1 {
+ avail = 1
+ }
+ for len(screen)+len(globals) > avail && len(screen) > 0 {
+ screen = screen[:len(screen)-1]
+ }
+ for len(screen)+len(globals) > avail && len(globals) > 0 {
+ globals = globals[:len(globals)-1]
+ }
+ // The rule between the screen's keys and the global ones is cosmetic, so it
+ // is only drawn when there is a line to spare.
+ rule := len(screen) > 0 && len(globals) > 0 && len(screen)+len(globals) < avail
+
+ rows := append(append([]helpKey{}, screen...), globals...)
+ keyW := 0
+ for _, r := range rows {
+ if w := displayWidth(r.key); w > keyW {
+ keyW = w
+ }
+ }
+ contentW := keyW
+ for _, r := range rows {
+ if w := keyW + 2 + displayWidth(r.desc); w > contentW {
+ contentW = w
+ }
+ }
+ if contentW < 1 {
+ contentW = 1
+ }
+
+ var b strings.Builder
+ row := func(r helpKey) {
+ b.WriteString(m.st.HintKey.Render(r.key + strings.Repeat(" ", keyW-displayWidth(r.key))))
+ b.WriteString(" " + m.st.HintDesc.Render(r.desc) + "\n")
+ }
+ for _, r := range screen {
+ row(r)
+ }
+ if rule {
+ b.WriteString(m.st.Rule.Render(strings.Repeat("─", contentW)) + "\n")
+ }
+ for _, r := range globals {
+ row(r)
+ }
+ panel := panelWith(m.st, m.helpTitle(), strings.TrimRight(b.String(), "\n"), m.width, true)
+ return lipgloss.Place(m.width, height, lipgloss.Center, lipgloss.Center, clamp(panel, m.width, height))
}
func (m Module) applyScroll(content string, width, height int) string {
@@ -1133,6 +1384,9 @@ func (m Module) renderBody() string {
}
func (m Module) renderTiny() string {
+ if m.helpOpen {
+ return clamp(" intake · help: ? or esc closes", m.width, 1)
+ }
what := "staging"
switch m.screen {
case ScreenJournal:
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
@@ -851,3 +851,297 @@ func TestTheAlreadyHaveChoices(t *testing.T) {
t.Error("the existing album should be forgotten when the album is skipped")
}
}
+
+func helpHas(keys []helpKey, key string) bool {
+ for _, k := range keys {
+ if k.key == key {
+ return true
+ }
+ }
+ return false
+}
+
+// The overlay opens and closes on every screen that has keys, and always names
+// the keys that work everywhere alongside the screen's own.
+func TestHelpOpensAndClosesOnEveryScreen(t *testing.T) {
+ screens := []Screen{ScreenBrowse, ScreenJournal, ScreenReview, ScreenCoverPicker, ScreenWorking, ScreenDone}
+ for _, screen := range screens {
+ m := testModule(120, 40)
+ m.screen = screen
+
+ got, _ := m.Update(key("?"))
+ m = got.(Module)
+ if !m.helpOpen {
+ t.Fatalf("screen %v: '?' did not open the help", screen)
+ }
+ out := m.render()
+ for _, want := range []string{"ctrl+c", "quit"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("screen %v: the help does not mention %q:\n%s", screen, want, out)
+ }
+ }
+
+ got, _ = m.Update(key("?"))
+ m = got.(Module)
+ if m.helpOpen {
+ t.Errorf("screen %v: '?' did not close the help", screen)
+ }
+
+ got, _ = m.Update(key("?"))
+ m = got.(Module)
+ got, _ = m.Update(key("esc"))
+ m = got.(Module)
+ if m.helpOpen {
+ t.Errorf("screen %v: esc did not close the help", screen)
+ }
+ }
+}
+
+// While the overlay is open it takes every key but the ones that close it, so a
+// stray press cannot change anything underneath.
+func TestHelpTakesTheKeysWhileItIsOpen(t *testing.T) {
+ m := testModule(120, 40)
+ got, _ := m.Update(albumsMsg{albums: albums()})
+ m = got.(Module)
+ m.cursor = 1
+ got, _ = m.Update(key("?"))
+ m = got.(Module)
+
+ before := m
+ for _, k := range []string{"/", "r", "b", "J", "n", "enter", "down", "q"} {
+ got, cmd := m.Update(key(k))
+ gotM := got.(Module)
+ if cmd != nil {
+ t.Errorf("%q produced a command while the help was open", k)
+ }
+ if !gotM.helpOpen {
+ t.Errorf("%q closed the help instead of being ignored", k)
+ }
+ if gotM.screen != before.screen || gotM.cursor != before.cursor || gotM.filtering {
+ t.Errorf("%q changed the screen underneath the help", k)
+ }
+ m = gotM
+ }
+
+ if _, cmd := m.Update(key("ctrl+c")); cmd == nil {
+ t.Error("ctrl+c should still quit with the help open")
+ }
+}
+
+// The overlay is not a screen: closing it leaves the review's scroll and the
+// list's cursor exactly where they were.
+func TestHelpLeavesTheScreenWhereItWas(t *testing.T) {
+ m := testModule(80, 12)
+ p := readyPlan()
+ for i := 0; i < 40; i++ {
+ p.Tracks = append(p.Tracks, p.Tracks[0])
+ }
+ got, _ := m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: p})
+ m = got.(Module)
+ got, _ = m.Update(key("down"))
+ m = got.(Module)
+ got, _ = m.Update(key("down"))
+ m = got.(Module)
+ scroll := m.scroll
+
+ got, _ = m.Update(key("?"))
+ m = got.(Module)
+ got, _ = m.Update(key("esc"))
+ m = got.(Module)
+
+ if m.screen != ScreenReview || m.step != StepMetadata || m.scroll != scroll {
+ t.Errorf("closing the help moved the review: screen=%v step=%v scroll=%d, want %d",
+ m.screen, m.step, m.scroll, scroll)
+ }
+
+ list := testModule(120, 40)
+ got, _ = list.Update(albumsMsg{albums: albums()})
+ list = got.(Module)
+ got, _ = list.Update(key("down"))
+ list = got.(Module)
+ cursor := list.cursor
+ got, _ = list.Update(key("?"))
+ list = got.(Module)
+ got, _ = list.Update(key("esc"))
+ list = got.(Module)
+ if list.cursor != cursor || list.screen != ScreenBrowse {
+ t.Errorf("closing the help moved the list: cursor=%d, want %d", list.cursor, cursor)
+ }
+}
+
+// The list is derived from what each screen actually does, including the states
+// inside a screen.
+func TestHelpListsWhatEachScreenDoes(t *testing.T) {
+ m := testModule(120, 40)
+ for _, want := range []string{"↑↓ / k j", "g / home", "G / end", "/", "enter", "b", "n", "J", "r", "q"} {
+ if !helpHas(m.helpKeys(), want) {
+ t.Errorf("browse help is missing %q: %v", want, m.helpKeys())
+ }
+ }
+
+ journal := testModule(120, 40)
+ journal.screen = ScreenJournal
+ for _, want := range []string{"enter", "r", "esc", "q"} {
+ if !helpHas(journal.helpKeys(), want) {
+ t.Errorf("journal help is missing %q: %v", want, journal.helpKeys())
+ }
+ }
+
+ menu := testModule(120, 40)
+ menu.screen, menu.journalMenu = ScreenJournal, 0
+ for _, want := range []string{"↑↓ / k j", "enter", "esc / q"} {
+ if !helpHas(menu.helpKeys(), want) {
+ t.Errorf("journal menu help is missing %q: %v", want, menu.helpKeys())
+ }
+ }
+
+ cover := testModule(120, 40)
+ cover.screen = ScreenCoverPicker
+ for _, want := range []string{"enter", "e", "esc", "q"} {
+ if !helpHas(cover.helpKeys(), want) {
+ t.Errorf("cover help is missing %q: %v", want, cover.helpKeys())
+ }
+ }
+
+ working := testModule(120, 40)
+ working.screen = ScreenWorking
+ for _, want := range []string{"↑ / k", "↓ / j", "pgup / pgdown", "g / home", "esc / q"} {
+ if !helpHas(working.helpKeys(), want) {
+ t.Errorf("working help is missing %q: %v", want, working.helpKeys())
+ }
+ }
+
+ done := testModule(120, 40)
+ done.screen = ScreenDone
+ for _, want := range []string{"↑↓ / k j", "pgup / pgdown", "enter / esc", "q"} {
+ if !helpHas(done.helpKeys(), want) {
+ t.Errorf("done help is missing %q: %v", want, done.helpKeys())
+ }
+ }
+}
+
+// The review screen's collision choices only exist while an album that is
+// already in the library is on screen, so the help changes with them.
+func TestHelpReflectsTheAlreadyHaveChoices(t *testing.T) {
+ m := testModule(120, 40)
+ m.pl = readyPlan()
+ m.current = scan.Album{Name: "boards", Dir: m.pl.Album.Dir, Status: scan.StatusReady}
+ m.screen, m.step = ScreenReview, StepMetadata
+ e := Existing{ArchiveDir: "/archive/an-artist/an-album"}
+ m.existing = &e
+
+ keys := m.helpKeys()
+ for _, want := range []string{"k", "r", "s"} {
+ if !helpHas(keys, want) {
+ t.Errorf("the collision choices should be in the help, missing %q: %v", want, keys)
+ }
+ }
+ if helpHas(keys, "↑↓ / k j") {
+ t.Error("k is taken by the collision choices, so the combined scroll keys should not be offered")
+ }
+
+ m.existing = nil
+ keys = m.helpKeys()
+ for _, no := range []string{"k", "r", "s"} {
+ if helpHas(keys, no) {
+ t.Errorf("nothing collides, so %q should not be in the help: %v", no, keys)
+ }
+ }
+}
+
+// The rendered overlay, not just the list, reflects the collision choices.
+func TestHelpOverlayShowsTheCollisionChoices(t *testing.T) {
+ m := testModule(120, 40)
+ m.pl = readyPlan()
+ m.current = scan.Album{Name: "boards", Dir: m.pl.Album.Dir, Status: scan.StatusReady}
+ m.screen, m.step = ScreenReview, StepMetadata
+ e := Existing{ArchiveDir: "/archive/an-artist/an-album"}
+ m.existing = &e
+
+ got, _ := m.Update(key("?"))
+ m = got.(Module)
+ out := m.render()
+ for _, want := range []string{"keep both", "replace it", "skip this album"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("the overlay does not show %q:\n%s", want, out)
+ }
+ }
+}
+
+// The overlay has to fit the terminal it is drawn on, including the small ones.
+func TestHelpFitsTheTerminal(t *testing.T) {
+ sizes := []struct{ w, h int }{{8, 8}, {10, 10}, {12, 4}, {20, 6}, {30, 10}, {50, 16}, {60, 18}, {80, 24}, {100, 30}, {200, 60}}
+ screens := []Screen{ScreenBrowse, ScreenJournal, ScreenReview, ScreenCoverPicker, ScreenWorking, ScreenDone}
+ for _, sz := range sizes {
+ for _, screen := range screens {
+ m := testModule(sz.w, sz.h)
+ m.screen = screen
+ got, _ := m.Update(key("?"))
+ m = got.(Module)
+ out := m.render()
+ for _, line := range strings.Split(out, "\n") {
+ if w := displayWidth(line); w > sz.w {
+ t.Errorf("%dx%d %v: line is %d cells wide: %q", sz.w, sz.h, screen, w, line)
+ }
+ }
+ if lines := strings.Count(out, "\n") + 1; lines > sz.h {
+ t.Errorf("%dx%d %v: %d lines, want at most %d", sz.w, sz.h, screen, lines, sz.h)
+ }
+ }
+ }
+}
+
+// Every footer advertises the key that opens the overlay, including the footers
+// that belong to a state inside a screen.
+func TestHelpIsInEveryFooter(t *testing.T) {
+ screens := []Screen{ScreenBrowse, ScreenJournal, ScreenReview, ScreenCoverPicker, ScreenWorking, ScreenDone}
+ for _, screen := range screens {
+ m := testModule(200, 60)
+ m.screen = screen
+ if footer := m.renderFooter(); !strings.Contains(footer, "?") || !strings.Contains(footer, "help") {
+ t.Errorf("screen %v: the footer does not advertise the help: %q", screen, footer)
+ }
+ }
+
+ filtering := testModule(200, 60)
+ filtering.filtering = true
+ if footer := filtering.renderFooter(); !strings.Contains(footer, "help") {
+ t.Errorf("the filtering footer does not advertise the help: %q", footer)
+ }
+
+ menu := testModule(200, 60)
+ menu.screen, menu.journalMenu = ScreenJournal, 0
+ if footer := menu.renderFooter(); !strings.Contains(footer, "help") {
+ t.Errorf("the journal menu footer does not advertise the help: %q", footer)
+ }
+}
+
+// A terminal too short for the whole list still shows the way out rather than
+// spilling past the bottom of the screen.
+func TestShortHelpKeepsTheWayOut(t *testing.T) {
+ m := testModule(60, 8)
+ m.screen = ScreenBrowse
+ got, _ := m.Update(key("?"))
+ m = got.(Module)
+ out := m.render()
+ if !strings.Contains(out, "close help") || !strings.Contains(out, "quit") {
+ t.Errorf("the way out should survive a short terminal:\n%s", out)
+ }
+}
+
+// Tiny terminals get one line, and it still says how to get back.
+func TestTinyHelpIsOneLine(t *testing.T) {
+ m := testModule(80, 5)
+ got, _ := m.Update(key("?"))
+ m = got.(Module)
+ if !m.helpOpen {
+ t.Fatal("'?' should open the help on a tiny terminal")
+ }
+ out := m.render()
+ if strings.Count(out, "\n") != 0 {
+ t.Errorf("the tiny help should be one line, got:\n%s", out)
+ }
+ if !strings.Contains(out, "help") {
+ t.Errorf("the tiny help should say what it is: %q", out)
+ }
+}