diff --git a/docs/tui.md b/docs/tui.md index b627485a..711d57ec 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -24,7 +24,8 @@ manually launched TUIs. The app has four sections: Mail, Contacts, Calendar and Journal. The context-sensitive shortcut bar is visible by default; press `?` to hide or restore it, and the choice is -remembered across restarts. +remembered across restarts. While it is hidden, `? help` at the left of the top bar says +how to bring it back. Mail navigation includes HEY's boxes plus separate Labels and Collections tabs: Shift+L opens Labels directly and Shift+K opens Collections. Previously Seen has its own tab after diff --git a/internal/tui/nav.go b/internal/tui/nav.go index bcbbffa2..ccd6fea6 100644 --- a/internal/tui/nav.go +++ b/internal/tui/nav.go @@ -324,10 +324,11 @@ func centerText(text string, width int) string { } // renderTopRule draws the top rule with HEY centered and the account -// aligned to the right, both bold: +// aligned to the right, both bold. A hint, when given, sits at the left end +// if there is room for it beside HEY: // -// ─────────── HEY ─────────── jz@example.com ── -func renderTopRule(width int, account string) string { +// ── ? help ── HEY ─────────── jz@example.com ── +func renderTopRule(width int, hint, account string) string { ruleStyle := lipgloss.NewStyle().Foreground(colorChrome) labelStyle := lipgloss.NewStyle().Foreground(colorChrome).Bold(true) @@ -346,6 +347,12 @@ func renderTopRule(width int, account string) string { } var b strings.Builder + const lead = 2 + if hintWidth := lipgloss.Width(hint) + 2; hint != "" && left >= lead+hintWidth+1 { + b.WriteString(ruleStyle.Render(strings.Repeat("─", lead))) + b.WriteString(" " + labelStyle.Render(hint) + " ") + left -= lead + hintWidth + } b.WriteString(ruleStyle.Render(strings.Repeat("─", left))) b.WriteString(" " + labelStyle.Render("HEY") + " ") b.WriteString(ruleStyle.Render(strings.Repeat("─", mid))) @@ -360,8 +367,12 @@ func renderTopRule(width int, account string) string { func renderHeader(m *model) string { var b strings.Builder - // Row 1: section rule + items - b.WriteString(renderTopRule(m.width, m.mailAccount.label)) + // Row 1: section rule + items. Hidden help leaves the one key that brings it back. + hint := "" + if m.help.hidden && m.canToggleHelp() { + hint = "? help" + } + b.WriteString(renderTopRule(m.width, hint, m.mailAccount.label)) b.WriteString("\n") b.WriteString(renderNavRow(sectionItems, int(m.section), m.focus == rowSection, m.width, true)) b.WriteString("\n") diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 1e293344..24edd16a 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -998,7 +998,8 @@ func (m model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } func (m model) canToggleHelp() bool { - if m.mailAccountPicker { + // An error box takes every key until it is dismissed, ? included. + if m.mailAccountPicker || m.err != nil { return false } if m.activeView == m.screenerView { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 6d5c77ee..1e7a56ac 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -248,6 +248,19 @@ func TestQuestionMarkTogglesHelpAndResizesContent(t *testing.T) { if !slices.Equal(saved, []bool{true}) { t.Errorf("saved preferences = %v, want [true]", saved) } + if top := lines[0]; !strings.Contains(top, " ? help ") { + t.Errorf("hidden help left no way back on the top rule: %q", top) + } + m.err = errors.New("HEY is not answering") + if top := strings.SplitN(stripANSI(m.View().Content), "\n", 2)[0]; strings.Contains(top, "? help") { + t.Errorf("the top rule offers ? while an error box would swallow it: %q", top) + } + updated, _ = m.Update(keyPress("?")) + m = updated.(model) + if !m.help.hidden { + t.Error("? acted through an error box") + } + m.err = nil updated, _ = m.Update(keyPress("?")) m = updated.(model) @@ -1436,8 +1449,30 @@ func TestChromeUsesBlueBoldConvention(t *testing.T) { } } +func TestTopRuleShowsAHintBesideHeyWhenThereIsRoom(t *testing.T) { + line := stripANSI(renderTopRule(80, "? help", "frank.castillo@example.com")) + if got := lipgloss.Width(line); got != 80 { + t.Errorf("top rule width = %d, want 80", got) + } + if !strings.HasPrefix(line, "── ? help ─") { + t.Errorf("the hint should open the rule: %q", line) + } + heyColumn := func(rule string) int { return lipgloss.Width(rule[:strings.Index(rule, "HEY")]) } + if want := stripANSI(renderTopRule(80, "", "frank.castillo@example.com")); heyColumn(line) != heyColumn(want) { + t.Errorf("the hint moved HEY: %q, want HEY where it is in %q", line, want) + } + if !strings.HasSuffix(line, "frank.castillo@example.com ──") { + t.Errorf("the account should stay right-aligned: %q", line) + } + + narrow := stripANSI(renderTopRule(40, "? help", "frank.castillo@example.com")) + if strings.Contains(narrow, "help") || lipgloss.Width(narrow) != 40 { + t.Errorf("a rule with no room for the hint = %q, want it left out", narrow) + } +} + func TestTopRuleCentersHeyAndRightAlignsAccount(t *testing.T) { - line := renderTopRule(80, "frank.castillo@example.com") + line := renderTopRule(80, "", "frank.castillo@example.com") if got := lipgloss.Width(line); got != 80 { t.Errorf("top rule width = %d, want 80", got) }