diff --git a/docs/architecture/cover-art.md b/docs/architecture/cover-art.md index 322b5b18..1222eaf4 100644 --- a/docs/architecture/cover-art.md +++ b/docs/architecture/cover-art.md @@ -81,7 +81,7 @@ cover can never be scrolled past, and `coverView` then gives the art every row t above it did not use. A list too short for even the floor keeps the divider and skips the art — the hint is worth more than a smear. -`x` lifts the cover (`coverPeeked`) and puts it back; the divider carries the hint and the +`z` lifts the cover (`coverPeeked`) and puts it back; the divider carries the hint and the hidden count, where the web app puts its buttons. Setting a cover closes it, so a box arrives covered rather than however the last one was left. Only the Imbox is coverable — that is haystack's rule (`Box::Imbox#coverable?`), and it is the same box that gets the diff --git a/docs/tui.md b/docs/tui.md index ba03a94c..22045222 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -61,7 +61,7 @@ uppercase belongs to Labels: | `t` | trash — every selected thread when any are selected, otherwise the one under the cursor. Not offered when it would target a bundle | | `!` | mark as spam | | `-` / `+` | ignore / stop ignoring | -| Space | select the thread for a bulk action (`e`, `u`, `t` or Ctrl+B) | +| Space or `x` | select the thread for a bulk action (`e`, `u`, `t` or Ctrl+B) | | Escape | clear the selection | | Ctrl+B | preview every bulk-reply recipient, then write one reply to every selected thread | | Ctrl+U | recall a delayed bulk reply while HEY's undo window is open | @@ -193,7 +193,7 @@ preview only reads the email; it doesn't screen the sender or tell them anything The Imbox can wear cover art, the way the HEY web app does: everything you have already read goes under it, so the box ends at what still wants your attention instead of trailing off into a month of receipts. The divider stays and says how much is under there — press -`x` to peek, `x` again to close it, or `9` to open Previously Seen on its own screen. +`z` to peek, `z` again to close it, or `9` to open Previously Seen on its own screen. Press Ctrl+V to choose one: `blobs`, `grid`, `peace`, `terrazzo`, `topo` or `waves`, the same six covers redrawn as characters, so they work in any terminal rather than only the diff --git a/internal/tui/bulk_reply.go b/internal/tui/bulk_reply.go index 8af2c226..a31afe79 100644 --- a/internal/tui/bulk_reply.go +++ b/internal/tui/bulk_reply.go @@ -287,7 +287,7 @@ func replyNoun(count int) string { func (v *mailView) startBulkReply() tea.Cmd { postingIDs := v.actionList().selectedIDs() if len(postingIDs) == 0 { - v.notice = "Select threads with space before starting a bulk reply" + v.notice = "Select threads with space or x before starting a bulk reply" return nil } requestID, ctx := v.requests.begin(v.vc.ctx, mailRequestBulkReply) diff --git a/internal/tui/bulk_reply_test.go b/internal/tui/bulk_reply_test.go index 5c39e62c..c70f62f1 100644 --- a/internal/tui/bulk_reply_test.go +++ b/internal/tui/bulk_reply_test.go @@ -120,7 +120,7 @@ func TestTUIBulkReplyRequiresExplicitSelection(t *testing.T) { if cmd := view.HandleContentKey(keyPress("ctrl+b")); cmd != nil { t.Fatal("bulk reply without a selection should not make a request") } - if view.notice != "Select threads with space before starting a bulk reply" { + if view.notice != "Select threads with space or x before starting a bulk reply" { t.Errorf("notice = %q", view.notice) } if requests := state.snapshot(); len(requests) != 0 { diff --git a/internal/tui/calendar_views.go b/internal/tui/calendar_views.go index 793d91cd..dbcb551d 100644 --- a/internal/tui/calendar_views.go +++ b/internal/tui/calendar_views.go @@ -650,7 +650,7 @@ func renderDayView(events, habits, countdowns []Recording, anchor, now time.Time // The day names itself above its hours — the subnav carries the calendar and the // view mode, so which day this is has nowhere else to be said — and the keys that - // move it sit on the same line, where the cover puts "x to peek". + // move it sit on the same line, where the cover puts "z to peek". b.WriteString(hintedSectionHeader(anchor.Local().Format("Monday, January 2"), hint, width)) b.WriteString("\n") diff --git a/internal/tui/content.go b/internal/tui/content.go index 485b10b6..16c4699b 100644 --- a/internal/tui/content.go +++ b/internal/tui/content.go @@ -721,7 +721,7 @@ func (c *contentList) view() string { if label := c.sectionLabelAt(i); label != "" { if c.cover != coverNone && sectionOf(p) == sectionPreviouslySeen { - fmt.Fprintln(&b, hintedSectionHeader(label, "x to cover", c.width)) + fmt.Fprintln(&b, hintedSectionHeader(label, "z to cover", c.width)) } else { fmt.Fprintln(&b, sectionHeader(label, c.width)) } @@ -850,7 +850,7 @@ func (c *contentList) view() string { // The threads themselves are not rendered at all — that is the whole point of a // cover, and it is why the art can have every row the postings did not use. func (c *contentList) coverView(hidden, rowsUsed int) string { - hint := fmt.Sprintf("%d hidden · x to peek", hidden) + hint := fmt.Sprintf("%d hidden · z to peek", hidden) header := hintedSectionHeader(sectionPreviouslySeen.label(), hint, c.width) rows := c.height - rowsUsed - 1 @@ -871,7 +871,7 @@ func sectionHeader(label string, width int) string { } // hintedSectionHeader is a section label with a hint on its right, where the HEY web -// app puts a section's buttons: "Previously Seen ──── 34 hidden · x to peek", or +// app puts a section's buttons: "Previously Seen ──── 34 hidden · z to peek", or // "Habits ──── b to manage". func hintedSectionHeader(label, hint string, width int) string { rule := lipgloss.NewStyle().Foreground(colorChrome) diff --git a/internal/tui/cover_picker.go b/internal/tui/cover_picker.go index fe30369d..9e8a5745 100644 --- a/internal/tui/cover_picker.go +++ b/internal/tui/cover_picker.go @@ -95,7 +95,7 @@ func (p *coverPicker) view(styles styles, width int) string { b.WriteString(preview + "\n\n") } b.WriteString(strings.Join(wrapText( - "A cover hides the threads you have already read. Press x to look under it.", + "A cover hides the threads you have already read. Press z to look under it.", contentWidth), "\n")) return b.String() } diff --git a/internal/tui/covers_test.go b/internal/tui/covers_test.go index 79d438b6..8f087805 100644 --- a/internal/tui/covers_test.go +++ b/internal/tui/covers_test.go @@ -556,7 +556,7 @@ func TestCoverHidesPreviouslySeen(t *testing.T) { if !strings.Contains(view, sectionPreviouslySeen.label()) { t.Error("covered list dropped the Previously Seen divider") } - if !strings.Contains(view, "3 hidden · x to peek") { + if !strings.Contains(view, "3 hidden · z to peek") { t.Error("covered list gave no hint about what is under the cover") } for _, posting := range list.postings[1:] { @@ -599,7 +599,7 @@ func TestPeekingLiftsTheCover(t *testing.T) { if !strings.Contains(view, list.postings[2].Name) { t.Error("peeking did not reveal the seen threads") } - if !strings.Contains(view, "x to cover") { + if !strings.Contains(view, "z to cover") { t.Error("a peeked list does not say how to put the cover back") } @@ -627,7 +627,7 @@ func TestCoverDropsTheArtBeforeTheDivider(t *testing.T) { list := coveredList(coverTopo, coverMinRows+2, false, false, true) view := list.view() - if !strings.Contains(view, "1 hidden · x to peek") { + if !strings.Contains(view, "1 hidden · z to peek") { t.Error("a short covered list lost its divider") } if rows := strings.Count(view, "\n") + 1; rows > coverMinRows+2 { @@ -643,7 +643,7 @@ func TestCoverWithNothingUnread(t *testing.T) { t.Errorf("itemCount = %d, want 0", got) } view := list.view() - if !strings.Contains(view, "2 hidden · x to peek") { + if !strings.Contains(view, "2 hidden · z to peek") { t.Error("an all-read Imbox does not say what is under the cover") } if rows := strings.Count(view, "\n") + 1; rows != 20 { diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 3f6c94a6..701aa49c 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -1110,7 +1110,7 @@ func (v *mailView) HelpBindings() []helpBinding { } bindings := []helpBinding{ {"enter", "open"}, - {"space", "select"}, + {"space/x", "select"}, {"ctrl+b", "bulk reply"}, {"r", "reply"}, {"f", "forward"}, @@ -1148,7 +1148,7 @@ func (v *mailView) HelpBindings() []helpBinding { {"/", "search"}, {"ctrl+s", "screener"}, {"c", "compose"}, - {"space", "select"}, + {"space/x", "select"}, {"ctrl+b", "bulk reply"}, {"r", "reply"}, {"f", "forward"}, @@ -1172,9 +1172,9 @@ func (v *mailView) HelpBindings() []helpBinding { helpBinding{"ctrl+r", "reload"}, ) if v.postingList.cover != coverNone { - peek := helpBinding{"x", "peek under cover"} + peek := helpBinding{"z", "peek under cover"} if v.postingList.coverPeeked { - peek = helpBinding{"x", "cover"} + peek = helpBinding{"z", "cover"} } bindings = append(bindings, peek) } @@ -1505,7 +1505,7 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { case "j": v.seenList.moveDown() return v.loadMoreSeenPostings() - case " ", "space": + case " ", "space", "x": v.seenList.toggleSelected() return nil case "ctrl+b": @@ -1559,7 +1559,7 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { return v.startSearch() case "c": return v.startCompose() - case " ", "space": + case " ", "space", "x": v.postingList.toggleSelected() return nil case "ctrl+b": @@ -1581,7 +1581,7 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { return nil } return v.startCollectionPicker() - case "x": + case "z": v.postingList.toggleCoverPeek() return nil case "ctrl+v": @@ -3025,7 +3025,7 @@ func (v *mailView) selectionHelpBindings(selected int) []helpBinding { bindings = append(bindings, helpBinding{"t", "trash"}) } bindings = append(bindings, - helpBinding{"space", "toggle"}, + helpBinding{"space/x", "toggle"}, helpBinding{"esc", "clear"}, helpBinding{"ctrl+b", "bulk reply"}, ) diff --git a/internal/tui/mail_selection_test.go b/internal/tui/mail_selection_test.go index 56db0107..d7caf747 100644 --- a/internal/tui/mail_selection_test.go +++ b/internal/tui/mail_selection_test.go @@ -401,7 +401,7 @@ func TestMailViewHelpBarOffersOnlyTheSelectionActions(t *testing.T) { if !strings.Contains(bindings[0].key, "2 selected") || bindings[0].desc != "" { t.Errorf("first binding = %+v, want the selected count", bindings[0]) } - for _, key := range []string{"e", "u", "t", "space", "esc", "ctrl+b"} { + for _, key := range []string{"e", "u", "t", "space/x", "esc", "ctrl+b"} { if !hasHelpBinding(bindings, key) { t.Errorf("help bar is missing %q: %v", key, bindings) } diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 82c01503..5fec82c9 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -11,6 +11,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "regexp" "slices" "strconv" "strings" @@ -3487,16 +3488,144 @@ func TestMailViewHaystackPickerAliases(t *testing.T) { } } -func TestMailViewCoverPeekUsesX(t *testing.T) { +func TestMailViewCoverPeekUsesZ(t *testing.T) { v := mailWithPostings() v.postingList.setCover(coverTopo) - v.HandleContentKey(keyPress("x")) + v.HandleContentKey(keyPress("z")) if !v.postingList.coverPeeked { - t.Fatal("x did not lift the cover") + t.Fatal("z did not lift the cover") + } + v.HandleContentKey(keyPress("z")) + if v.postingList.coverPeeked { + t.Fatal("z did not replace the cover") + } +} + +// x selects like space does, matching the HEY desktop app — including on a covered +// Imbox, where x used to lift the cover (that is z's key now). +func TestMailViewSelectsWithXLikeSpace(t *testing.T) { + v := mailWithPostings() + v.postingList.setCover(coverTopo) + + v.HandleContentKey(keyPress("x")) + if ids := v.postingList.selectedIDs(); len(ids) != 1 || ids[0] != 100 { + t.Fatalf("selected after x = %v, want [100]", ids) + } + if v.postingList.coverPeeked { + t.Error("x lifted the cover instead of selecting") + } + v.HandleContentKey(keyPress("x")) + if ids := v.postingList.selectedIDs(); len(ids) != 0 { + t.Errorf("selected after second x = %v, want none", ids) + } + + seen := mailWithPostings() + seen.seenActive = true + seen.seenList.setPostings(testPostings()) + seen.HandleContentKey(keyPress("x")) + if ids := seen.seenList.selectedIDs(); len(ids) != 1 || ids[0] != 100 { + t.Errorf("seen screen selected after x = %v, want [100]", ids) + } +} + +func TestMailViewSeenScreenXDeselectsWithoutLeaving(t *testing.T) { + v := mailWithPostings() + v.seenActive = true + v.seenList.setPostings(testPostings()) + + v.HandleContentKey(keyPress("x")) + if ids := v.seenList.selectedIDs(); len(ids) != 1 || ids[0] != 100 { + t.Fatalf("seen screen selected after x = %v, want [100]", ids) } v.HandleContentKey(keyPress("x")) + if ids := v.seenList.selectedIDs(); len(ids) != 0 { + t.Errorf("seen screen selected after second x = %v, want none", ids) + } + if !v.seenActive { + t.Error("x left the Previously Seen screen") + } +} + +func TestMailViewSelectionHelpNamesEveryToggleKey(t *testing.T) { + v := mailWithPostings() + v.HandleContentKey(keyPress("x")) + + var toggle string + for _, binding := range v.HelpBindings() { + if binding.desc == "toggle" { + toggle = binding.key + } + } + if toggle != "space/x" { + t.Fatalf("selection help toggle key = %q, want %q", toggle, "space/x") + } + for _, step := range []struct { + key string + want int + }{{" ", 0}, {" ", 1}, {"x", 0}} { + v.HandleContentKey(keyPress(step.key)) + if ids := v.postingList.selectedIDs(); len(ids) != step.want { + t.Fatalf("selected after %q = %v, want %d", step.key, ids, step.want) + } + } +} + +// The peek works on a list with a selection standing, and leaves the selection alone: +// lifting the cover changes what is shown, not what a bulk action will act on. +func TestMailViewCoverPeekKeepsTheSelection(t *testing.T) { + v := mailWithPostings() + v.postingList.setCover(coverTopo) + v.HandleContentKey(keyPress("x")) + + for _, wantPeeked := range []bool{true, false} { + v.HandleContentKey(keyPress("z")) + if v.postingList.coverPeeked != wantPeeked { + t.Fatalf("coverPeeked after z = %v, want %v", v.postingList.coverPeeked, wantPeeked) + } + if ids := v.postingList.selectedIDs(); len(ids) != 1 || ids[0] != 100 { + t.Fatalf("selected after z = %v, want [100]", ids) + } + } +} + +// Every place that tells the reader how to look under the cover names the key that +// does it: the cover picker's footer, the help bar, and the divider either way round. +func TestMailViewCoverHintsNameThePeekKey(t *testing.T) { + footer := regexp.MustCompile(`Press (\S+) to look under it`).FindStringSubmatch(newCoverPicker(coverTopo).view(newStyles(), 120)) + if footer == nil { + t.Fatal("cover picker does not name the peek key") + } + key := footer[1] + + list := coveredList(coverTopo, 20, false, true) + if hint := regexp.MustCompile(`hidden · (\S+) to peek`).FindStringSubmatch(ansi.Strip(list.view())); hint == nil || hint[1] != key { + t.Errorf("covered divider hint = %v, want %q", hint, key) + } + list.toggleCoverPeek() + if hint := regexp.MustCompile(`(\S+) to cover`).FindStringSubmatch(ansi.Strip(list.view())); hint == nil || hint[1] != key { + t.Errorf("peeked divider hint = %v, want %q", hint, key) + } + + v := mailWithPostings() + v.postingList.setCover(coverTopo) + if !hasHelpBinding(v.HelpBindings(), key) { + t.Errorf("help bar does not offer %q for the peek: %+v", key, v.HelpBindings()) + } + v.HandleContentKey(keyPress(key)) + if !v.postingList.coverPeeked { + t.Fatalf("%q, the key the picker names, did not lift the cover", key) + } + if ids := v.postingList.selectedIDs(); len(ids) != 0 { + t.Errorf("the peek key selected %v", ids) + } + for _, binding := range v.HelpBindings() { + if binding.desc == "cover" && binding.key != key { + t.Errorf("help bar puts the cover back with %q, want %q", binding.key, key) + } + } + v.HandleContentKey(keyPress(key)) if v.postingList.coverPeeked { - t.Fatal("x did not replace the cover") + t.Errorf("%q did not put the cover back", key) } } @@ -4563,7 +4692,7 @@ func TestMailViewSeenScreenHelpOffersTheThreadActions(t *testing.T) { v.seenActive = true bindings := v.HelpBindings() - for _, key := range []string{"enter", "space", "ctrl+b", "a", "l", "u", "t", "v", "b", "n"} { + for _, key := range []string{"enter", "space/x", "ctrl+b", "a", "l", "u", "t", "v", "b", "n"} { if !hasHelpBinding(bindings, key) { t.Errorf("seen screen help misses %q: %+v", key, bindings) }