Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture/cover-art.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/bulk_reply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/bulk_reply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/calendar_views.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
6 changes: 3 additions & 3 deletions internal/tui/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/cover_picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
8 changes: 4 additions & 4 deletions internal/tui/covers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:] {
Expand Down Expand Up @@ -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")
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
16 changes: 8 additions & 8 deletions internal/tui/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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"},
Expand All @@ -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)
}
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Comment thread
robzolkos marked this conversation as resolved.
Resolved
v.postingList.toggleSelected()
return nil
case "ctrl+b":
Expand All @@ -1581,7 +1581,7 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd {
return nil
}
return v.startCollectionPicker()
case "x":
case "z":
Comment thread
robzolkos marked this conversation as resolved.
Resolved
v.postingList.toggleCoverPeek()
return nil
case "ctrl+v":
Expand Down Expand Up @@ -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"},
)
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/mail_selection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
139 changes: 134 additions & 5 deletions internal/tui/mail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
}
Expand Down
Loading