diff --git a/docs/changes/unreleased/1541-search-card-follows-pointer.md b/docs/changes/unreleased/1541-search-card-follows-pointer.md new file mode 100644 index 000000000..51a033028 --- /dev/null +++ b/docs/changes/unreleased/1541-search-card-follows-pointer.md @@ -0,0 +1,9 @@ +--- +kind: fixed +title: Home search's card follows the pointer and returns to the cursor's match +pr: 1541 +surface: [chat, docs] +invalidates: + - "Since #1071, pointing at a Home search match could draw no card, and leaving the list left the card on the hovered match. A hover moved the keyboard cursor, and the card read only that cursor. While a search is typed, the pointer now previews the match under it without moving the cursor, and moving off the matches gives the card back to the cursor's match. Resting home keeps one shared selection." + - "A `folder gone` search match has a card; its unavailable folder actions say so on it." +--- diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index fcb5ee87a..076cba619 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1952,7 +1952,8 @@ a `made ` line in `since you left`, and where you left off is the line und selects it; the next arrow key takes over and removes the mouse highlight. A stationary pointer cannot override that choice. `→` and its options always act on the latest selection. Leaving the list keeps that selection, and there is no separate right-hand -preview at rest. +preview at rest. A typed search is different: its pointer previews a match without moving +the keyboard cursor, and leaving the match restores the cursor's card. **The one card left is beside a search.** On a frame 136 columns or wider, while you are typing, the match under the cursor has a card to the right of the list (*The card beside a @@ -1968,7 +1969,9 @@ and each thing that card said has a place: `keeping an eye on` is the standing p **While something is typed, on a frame 136 columns or wider, a card stands to the right of the matches**, about the match under the cursor — or the one under your pointer while it is -on one. It is read top to bottom as bands with blank lines between them, and each band is +on one. Moving the pointer off the matches gives the card back to the cursor's match. +Even a match whose row says `folder gone` has a card; its unavailable folder actions say +so there. The card is read top to bottom as bands with blank lines between them, and each band is drawn only when it has something to say: 1. the conversation's **name**, the brightest text on the screen; diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 51822a70f..807355d59 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -2309,12 +2309,20 @@ func (h *homeView) focusedLine() (homeLine, bool) { return h.lines[h.cursor], true } -// previewLine is the single selected row, shared by the card and its actions. -// Mouse navigation moves this cursor too; a stale hover never chooses a verb. -func (h *homeView) previewLine() (homeLine, bool) { return h.focusedLine() } +// previewLine is the row the card and its actions answer. A typed search keeps +// the keyboard cursor while the pointer temporarily previews another match. +func (h *homeView) previewLine() (homeLine, bool) { + if h.searching() && h.hover >= 0 && h.hover < len(h.lines) && h.lines[h.hover].stop() { + return h.lines[h.hover], true + } + return h.focusedLine() +} -// previewAt is the selected line number for readers that need its position. +// previewAt is the previewed line number for readers that need its position. func (h *homeView) previewAt() int { + if h.searching() && h.hover >= 0 && h.hover < len(h.lines) && h.lines[h.hover].stop() { + return h.hover + } if _, ok := h.focusedLine(); !ok { return homeNoLine } @@ -4324,7 +4332,12 @@ func (a *app) homeHover(x, y int) tea.Cmd { } if at >= 0 && at < len(a.home.lines) && a.home.lines[at].stop() { a.home.hover = at - a.selectPlaceRow(&a.home.cursor, at) + // A SEARCH'S POINTER IS A TEMPORARY PREVIEW. Leaving the match + // gives the card back to the keyboard cursor, so motion must not + // move that cursor while the drop-up is open. + if !a.home.searching() { + a.selectPlaceRow(&a.home.cursor, at) + } } } if a.home.hover != was { diff --git a/internal/tui3/homesearchcard_test.go b/internal/tui3/homesearchcard_test.go new file mode 100644 index 000000000..243a55b10 --- /dev/null +++ b/internal/tui3/homesearchcard_test.go @@ -0,0 +1,126 @@ +package tui3 + +import ( + "path/filepath" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" +) + +// searchCardLab opens three missing-folder matches in one Home search. +func searchCardLab(t *testing.T, width int) *app { + t.Helper() + lab := newHomeLab(t) + now := time.Now() + alpha := lab.session("-alpha", "aaaa000000000001", "Seed Alpha", filepath.Join(lab.work, "missing-alpha"), now) + lab.session("-beta", "bbbb000000000001", "Seed Beta", filepath.Join(lab.work, "missing-beta"), now.Add(-time.Minute)) + lab.session("-gamma", "cccc000000000001", "Seed Gamma", filepath.Join(lab.work, "missing-gamma"), now.Add(-2*time.Minute)) + a := lab.app(alpha) + a.width, a.height = width, 40 + a.openHome() + for _, letter := range "Seed" { + a.homeKey(key(string(letter))) + } + a.homeFrame(a.width, a.height) + return a +} + +// searchCardOnScreen reads only the space reserved for the card, so a match in +// the list cannot make an absent card look present. +func searchCardOnScreen(a *app) string { + left, right := homeColumns(a.width) + if right == 0 { + return "" + } + rows, _, _, _ := a.homeFrame(a.width, a.height) + var card []string + for _, row := range rows { + card = append(card, ansi.Strip(ansi.Cut(row, left+homeGutter, a.width))) + } + return strings.Join(card, "\n") +} + +func TestHomeSearchCardFollowsKeyboardCursor(t *testing.T) { + a := searchCardLab(t, 136) + a.homeKey(key("up")) + a.homeKey(key("up")) + // A pointer may briefly preview another match; the next arrow still walks + // from the keyboard's match and the card follows that walk. + at := homeLineOfKind(t, a, homeSession, "missing-beta") + drive(t, a, tea.MouseMotionMsg{X: 4, Y: homeLineY(t, a, at)}) + drive(t, a, key("up")) + line, ok := a.home.focusedLine() + if !ok || line.kind != homeSession { + t.Fatalf("the arrow did not pick a match: %+v", line) + } + if got := homeName(line.row); got != "Seed Beta" { + t.Fatalf("the arrow walked from the pointer instead of the cursor: got %q", got) + } + if card := searchCardOnScreen(a); !strings.Contains(card, homeName(line.row)) { + t.Fatalf("the cursor's match has no card on the right:\n%s", card) + } +} + +func TestHomeSearchCardFollowsPointerMatch(t *testing.T) { + a := searchCardLab(t, 180) + cursor := a.home.cursor + at := homeLineOfKind(t, a, homeSession, "missing-beta") + drive(t, a, tea.MouseMotionMsg{X: 4, Y: homeLineY(t, a, at)}) + if card := searchCardOnScreen(a); !strings.Contains(card, "Seed Beta") { + t.Fatalf("the pointer's match has no card on the right:\n%s", card) + } + if a.home.cursor != cursor { + t.Fatalf("the pointer moved the keyboard cursor from %d to %d", cursor, a.home.cursor) + } + // The copy chord must act on the card the pointer brought up before the + // keyboard takes selection back from that pointer. + path := a.home.lines[at].row.Workspace + drive(t, a, key("ctrl+y")) + if got := a.home.msg; got != "copied "+path { + t.Fatalf("the visible card is about Seed Beta but copy answered %q", got) + } +} + +func TestHomeSearchCardReturnsToCursorWhenPointerLeaves(t *testing.T) { + a := searchCardLab(t, 180) + a.homeKey(key("up")) + a.homeKey(key("up")) + line, ok := a.home.focusedLine() + if !ok || line.kind != homeSession { + t.Fatalf("two up keys did not pick a match: %+v", line) + } + want := homeName(line.row) + at := homeLineOfKind(t, a, homeSession, "missing-beta") + y := homeLineY(t, a, at) + drive(t, a, tea.MouseMotionMsg{X: 4, Y: y}) + left, _ := homeColumns(a.width) + drive(t, a, tea.MouseMotionMsg{X: left + homeGutter + 1, Y: y}) + if card := searchCardOnScreen(a); !strings.Contains(card, want) { + t.Fatalf("off the list, the cursor's card did not return: want %q in\n%s", want, card) + } +} + +func TestHomeSearchHasNoSideCardBelow136Columns(t *testing.T) { + if homeCardMin != 136 { + t.Fatalf("the manual's card floor is 136 columns, code says %d", homeCardMin) + } + a := searchCardLab(t, 135) + a.homeKey(key("up")) + a.homeKey(key("up")) + cursor := a.home.cursor + at := homeLineOfKind(t, a, homeSession, "missing-beta") + drive(t, a, tea.MouseMotionMsg{X: 4, Y: homeLineY(t, a, at)}) + if card := searchCardOnScreen(a); card != "" { + t.Fatalf("a narrow search drew a side card:\n%s", card) + } + rows, _, _, _ := a.homeFrame(a.width, a.height) + if frame := ansi.Strip(strings.Join(rows, "\n")); strings.Count(frame, "Seed Beta") != 1 { + t.Fatalf("at 135 columns the hovered match appears outside its one list row:\n%s", frame) + } + if a.home.cursor != cursor { + t.Fatalf("at 135 columns the pointer moved the keyboard cursor from %d to %d", cursor, a.home.cursor) + } +} diff --git a/internal/tui3/placekeys.go b/internal/tui3/placekeys.go index 98339f28c..e2b7a4e6f 100644 --- a/internal/tui3/placekeys.go +++ b/internal/tui3/placekeys.go @@ -71,7 +71,15 @@ import ( // was five copies of the same two lines — a place added later that forgot them // would be a room `tab` could not leave. func (a *app) placeKeyPress(msg tea.KeyPressMsg) tea.Cmd { - a.keyboardPlaceSelection() + // A SEARCH CARD'S KEYS ACT ON THE ROW IT SHOWS. The usual keyboard handoff + // clears hover before the place handles a key; doing that for a card verb + // would make the key act on the cursor's different row. Keep the pointer + // through this one dispatch, then retire it so the next key owns the cursor. + if a.at(pageHome) && a.home.searching() && a.home.hover >= 0 && homeSearchCardKey(msg.String()) { + defer a.keyboardPlaceSelection() + } else { + a.keyboardPlaceSelection() + } pl := a.showing() if pl == nil { return nil @@ -110,6 +118,15 @@ func (a *app) placeKeyPress(msg tea.KeyPressMsg) tea.Cmd { return pl.key(a, msg) } +// homeSearchCardKey names keys whose subject is the visible search card. +func homeSearchCardKey(key string) bool { + switch key { + case "right", "ctrl+t", "ctrl+o", "ctrl+y", "ctrl+e", "ctrl+x", effortKey: + return true + } + return false +} + // placeKey is the router's claim on one keypress. It reports whether it took it; // when it did not, the place's own handler carries on exactly as it did before. func (a *app) placeKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {