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
9 changes: 9 additions & 0 deletions docs/changes/unreleased/1541-search-card-follows-pointer.md
Original file line number Diff line number Diff line change
@@ -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."
---
7 changes: 5 additions & 2 deletions internal/manual/chat/home.md
Original file line number Diff line number Diff line change
Expand Up @@ -1952,7 +1952,8 @@ a `made <file>` 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
Expand All @@ -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;
Expand Down
23 changes: 18 additions & 5 deletions internal/tui3/home.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
126 changes: 126 additions & 0 deletions internal/tui3/homesearchcard_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
19 changes: 18 additions & 1 deletion internal/tui3/placekeys.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Loading