aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authoruser <user@clank>2026-09-08 16:58:24 +0200
committeruser <user@clank>2026-09-08 16:58:24 +0200
commit935fb7e93726dcb8ab0529c2dbcb6b5e75656794 (patch)
tree824b46e4cff68b7d4fa0c333717684ce273c9b10 /src
parentrelease: v0.1.6 paperwork -- formula and PKGBUILD pinned (diff)
downloadslosh-935fb7e93726dcb8ab0529c2dbcb6b5e75656794.tar.gz
slosh-935fb7e93726dcb8ab0529c2dbcb6b5e75656794.tar.xz
slosh-935fb7e93726dcb8ab0529c2dbcb6b5e75656794.zip
search: a scrollback search bar on /, matches highlighted
C-a / opens an inline search bar on the focused pane's status row, on lib-vt's GhosttySearch (D4's pattern: the scrollback walk, reflow and alt-screen survival are the library's, the UI and the wiring are ours). Type to search live; the viewport follows the current match; up/down (or C-n/C-p) step through them with wraparound; Enter keeps your place, Escape snaps back. A k-of-n counter reads on the right, or "no matches". Every match on screen is tinted, and the one the bar is on is tinted apart -- warm gold by default, painted after the shader pass so the scrolled wash does not dilute a match you are hunting for. Four themeable colours (search_fg/bg, search_cur_fg/bg). Bare / took the key from help, which keeps ?; that needed the legacy decoder to read the shift out of a punctuation byte the way it already did for capitals -- without it ? and / decode identically (SLASH, no mods) and cannot be two keys on a client without the kitty protocol. tests/test_search.py drives it end to end (39 checks): the bar, the counter, stepping and wrap, all-match vs current highlighting, Enter/ Escape, new output updating the count, case-insensitivity, the keyboard belonging to the bar, click-away, and that / and ? no longer collide.
Diffstat (limited to 'src')
-rw-r--r--src/app.c17
-rw-r--r--src/app_draw.c95
-rw-r--r--src/app_ui.c118
-rw-r--r--src/config.c29
-rw-r--r--src/input.c19
-rw-r--r--src/pane.c163
6 files changed, 436 insertions, 5 deletions
diff --git a/src/app.c b/src/app.c
index 7ff584b..74d49ff 100644
--- a/src/app.c
+++ b/src/app.c
@@ -1598,6 +1598,7 @@ bool run_action(app_t *a, action_t act) {
case ACT_SCROLL_BOTTOM:
pane_scroll_edge(cur(a)->focus->pane, false);
return true;
+ case ACT_SEARCH: search_begin(a); return true;
case ACT_FINDER:
case ACT_PALETTE:
a->picker = act == ACT_PALETTE ? PICK_PALETTE : PICK_FINDER;
@@ -1682,6 +1683,10 @@ void app_event(app_t *a, const input_event_t *ev) {
rename_key(a, ev);
return;
}
+ if (a->searching && ev->kind == EV_KEY) {
+ search_key(a, ev);
+ return;
+ }
if (a->picker && ev->kind == EV_KEY) {
picker_key(a, ev);
return;
@@ -1771,6 +1776,18 @@ void app_event(app_t *a, const input_event_t *ev) {
if (!action || strcmp(action, own) != 0) rename_end(a, true);
}
+ /* The same rule for the search bar, and for the same reason: clicking
+ * away is how leaving a field commits it. Accepts (keeps the viewport)
+ * rather than cancels -- the click says "done here", not "undo it". The
+ * wheel is not a click: scrolling the pane you are searching is part of
+ * searching it, and must not swallow the bar. */
+ if (a->searching && ev->maction == MOUSE_PRESS && ev->button != MBTN_FOUR &&
+ ev->button != MBTN_FIVE) {
+ char own[48];
+ snprintf(own, sizeof own, "search:%u", a->search_id);
+ if (!action || strcmp(action, own) != 0) search_end(a, true);
+ }
+
if (!a->ptr_valid || ev->mx != a->ptr_x || ev->my != a->ptr_y)
a->ptr_still_since = now_ms_();
a->ptr_x = ev->mx;
diff --git a/src/app_draw.c b/src/app_draw.c
index 700b9b6..a3221e8 100644
--- a/src/app_draw.c
+++ b/src/app_draw.c
@@ -194,8 +194,97 @@ struct row_btn {
char action[48];
};
+/* The search bar, on the row the epitaph and the OSC 5577 status use: the
+ * bottom border on a classic frame, the last content row in compact. It wins
+ * the whole row while it is up -- a query and a status would be two sentences
+ * on one line -- and comes back the moment the search closes.
+ *
+ * Left, the field: `/ query█`, in the rename editor's colours, because it is
+ * the same kind of thing (typing that is not going to the program) and should
+ * announce itself the same way. Right, the answer: `k of n`, or `no matches`,
+ * which is the difference between "narrow it" and "it is not there". */
+static bool draw_search_bar(app_t *a, screen_t *s, node_t *leaf) {
+ if (!a->searching || a->search_id != leaf->id) return false;
+ rect_t r = leaf->rect;
+ if (r.w < 8 || r.h < 3) return true; /* too small to draw, still claimed */
+ uint16_t y = (uint16_t)(r.y + r.h - 1);
+ uint16_t left = (uint16_t)(r.x + 1), right = (uint16_t)(r.x + r.w - 1);
+
+ /* The counter first, so the field knows what is left. Only when there is a
+ * needle: `0 of 0` under an empty field would be an answer to a question
+ * nobody asked yet. */
+ uint16_t cx = right;
+ if (a->search_buf[0]) {
+ size_t idx = 0, total = 0;
+ bool selected = pane_search_pos(leaf->pane, &idx, &total);
+ char count[48];
+ if (selected)
+ snprintf(count, sizeof count, " %zu of %zu ", idx + 1, total);
+ else if (total)
+ snprintf(count, sizeof count, " %zu ", total);
+ else
+ snprintf(count, sizeof count, " no matches ");
+ uint16_t cw = cells(count);
+ if (left + cw + 6 < right) { /* only when a usable field remains */
+ cx = (uint16_t)(right - cw);
+ screen_text(s, cx, y, count, total ? HINT_C : DEAD_C, NO_COLOR,
+ total ? 0 : ATTR_BOLD);
+ }
+ }
+
+ /* The field: head scrolled off when too long, whole characters only --
+ * the cursor is the one thing that must stay on screen while typing. */
+ char buf[160];
+ int len = snprintf(buf, sizeof buf, " / %s\u2588 ", a->search_buf);
+ if (len < 0) len = 0;
+ if ((size_t)len >= sizeof buf) len = (int)strlen(buf);
+ uint16_t avail = (uint16_t)(cx > left ? cx - left : 0);
+ if (len > (int)avail) {
+ size_t off = (size_t)(len - (int)avail);
+ while (off < (size_t)len && ((unsigned char)buf[off] & 0xC0) == 0x80) off++;
+ memmove(buf, buf + off, (size_t)len - off + 1);
+ len = (int)((size_t)len - off);
+ }
+ uint16_t drawn = screen_text(s, left, y, buf, RENAME_FG, RENAME_BG, 0);
+ /* Claims its cells, like the epitaph: not a button, but owning the row is
+ * what keeps the armed split guide from ruling a line through the query --
+ * and what lets a click on the bar be "not away", so it does not commit. */
+ if (drawn) {
+ char action[48];
+ snprintf(action, sizeof action, "search:%u", leaf->id);
+ hit_add(&s->hits, left, y, drawn, 1, action);
+ }
+ return true;
+}
+
+/* Tint the pane's on-screen search matches. The current match is painted in
+ * its own colour and made bold; the rest share the plain match colour. Our
+ * own inverse is cleared on the cells we touch, so a match landing on selected
+ * text reads as a match rather than a double-negative. */
+static void draw_search_marks(app_t *a, screen_t *s, node_t *n) {
+ if (!a->searching || a->search_id != n->id || !n->pane) return;
+ pane_mark_t marks[256];
+ size_t nm = pane_search_marks(n->pane, marks, 256);
+ for (size_t i = 0; i < nm; i++) {
+ if (marks[i].row >= n->content.h) continue;
+ uint16_t y = (uint16_t)(n->content.y + marks[i].row);
+ color_t fg = marks[i].current ? SEARCH_CUR_FG : SEARCH_FG;
+ color_t bg = marks[i].current ? SEARCH_CUR_BG : SEARCH_BG;
+ for (uint16_t x = marks[i].x0; x <= marks[i].x1 && x < n->content.w; x++) {
+ cell_t *c = screen_at(s, (uint16_t)(n->content.x + x), y);
+ if (!c) continue;
+ c->fg = fg;
+ c->bg = bg;
+ c->attrs &= (uint16_t)~ATTR_INVERSE;
+ if (marks[i].current) c->attrs |= ATTR_BOLD;
+ }
+ }
+}
+
static void draw_pane_status(app_t *a, screen_t *s, node_t *leaf, color_t fg,
bool focused) {
+ if (draw_search_bar(a, s, leaf)) return;
+
struct row_btn row[8];
size_t nbtn = 0;
char status[256] = {0};
@@ -1647,6 +1736,12 @@ static void draw_cb(node_t *n, void *ud) {
* is not decoration and must not be dimmed along with the thing it is
* offered on. */
shade_leaf(d->a, d->s, n);
+ /* Search match highlights, after the shader pass rather than before it: a
+ * match is on screen because it was searched for, so it wants to be crisp,
+ * not washed by the `scrolled` fade over the very history it sits in. Every
+ * on-screen match in `search`; the one the bar is on in `search_cur`, which
+ * is what tells "this one" from "all of them". */
+ draw_search_marks(d->a, d->s, n);
/* A compact pane's chrome is the ring of shared line it sits inside — one
* cell out on every side — rather than the rect's own edge; the state
* passes (a bell's flash, a theme's chrome chain) land there instead. The
diff --git a/src/app_ui.c b/src/app_ui.c
index 21b3529..11e667a 100644
--- a/src/app_ui.c
+++ b/src/app_ui.c
@@ -989,6 +989,112 @@ bool rename_key(app_t *a, const input_event_t *ev) {
return true;
}
+/* ---- searching a pane's scrollback -------------------------------------- *
+ *
+ * The third inline editor, beside the rename and the purpose: a query typed
+ * where its answer is, not a modal over it. The bar draws on the searched
+ * pane's status row (draw_pane_status), the results are the pane's own: every
+ * on-screen match is tinted (draw_search_marks), the current one apart from
+ * the rest, and the viewport follows the current match as it moves -- so the
+ * scrolled state, the ▲ indicator and the edge fades all say what the search
+ * did without being told.
+ */
+
+void search_begin(app_t *a) {
+ node_t *n = cur(a)->focus;
+ if (!n || !n->pane) return;
+ a->searching = true;
+ a->search_id = n->id;
+ /* Seeded empty, unlike a rename: a rename edits a label that exists, a
+ * search starts with a question that does not. A remembered last query
+ * would be one keystroke saved and one "why did it jump" per reopening. */
+ a->search_buf[0] = 0;
+}
+
+/* Close the bar. `keep_view` is the difference between the two ways out:
+ * Enter accepts -- you searched to read something, so the viewport stays on
+ * it -- while Escape cancels and snaps back to the live view, the place the
+ * search lifted you from. The needle and the highlight go either way; the
+ * match found is a place, not a selection somebody asked to keep. */
+void search_end(app_t *a, bool keep_view) {
+ if (!a->searching) return;
+ node_t *n = pane_by_id(a, a->search_id);
+ if (n && n->pane) {
+ pane_search_close(n->pane);
+ if (!keep_view && pane_scrolled(n->pane)) pane_scroll_edge(n->pane, false);
+ }
+ a->searching = false;
+ a->search_id = 0;
+ a->search_buf[0] = 0;
+}
+
+/* Returns true when the search bar consumed the event. Like the picker it
+ * consumes everything while open: it is a text field, and an unrecognised
+ * key that fell through would type into the pane behind it. */
+bool search_key(app_t *a, const input_event_t *ev) {
+ if (!a->searching) return false;
+ if (ev->kind != EV_KEY || ev->action == KEY_RELEASE) return true;
+
+ node_t *n = pane_by_id(a, a->search_id);
+ if (!n || !n->pane) { /* the subject went away mid-question */
+ a->searching = false;
+ a->search_id = 0;
+ a->search_buf[0] = 0;
+ return true;
+ }
+ pane_t *p = n->pane;
+ bool ctrl = (ev->mods & MOD_CTRL) != 0;
+
+ /* The same C-u the picker and the rename answer to: the program's places
+ * you type into clear the same way, or none of them can be trusted. */
+ if (ctrl && (ev->key == GHOSTTY_KEY_U || ev->unshifted == 'u')) {
+ a->search_buf[0] = 0;
+ pane_search_set(p, "");
+ return true;
+ }
+ /* The emacs pair, the way the picker has it: next is *older* -- up into
+ * history, the direction a search from the prompt means. */
+ if (ctrl && (ev->key == GHOSTTY_KEY_N || ev->unshifted == 'n')) {
+ pane_search_step(p, true);
+ return true;
+ }
+ if (ctrl && (ev->key == GHOSTTY_KEY_P || ev->unshifted == 'p')) {
+ pane_search_step(p, false);
+ return true;
+ }
+
+ switch (ev->key) {
+ case GHOSTTY_KEY_ESCAPE: search_end(a, false); return true;
+ case GHOSTTY_KEY_ENTER: search_end(a, true); return true;
+ /* Up is older and Down is newer, which is what the arrows mean while the
+ * matches run bottom-to-top through the scrollback. */
+ case GHOSTTY_KEY_ARROW_UP: pane_search_step(p, true); return true;
+ case GHOSTTY_KEY_ARROW_DOWN: pane_search_step(p, false); return true;
+ case GHOSTTY_KEY_BACKSPACE: {
+ /* A character, not a byte (the rename editor's lesson). */
+ size_t l = strlen(a->search_buf);
+ while (l && ((unsigned char)a->search_buf[l - 1] & 0xC0) == 0x80) l--;
+ if (l) l--;
+ a->search_buf[l] = 0;
+ pane_search_set(p, a->search_buf);
+ return true;
+ }
+ default: break;
+ }
+ if (!ctrl && ev->text_len && (unsigned char)ev->text[0] >= 0x20) {
+ size_t l = strlen(a->search_buf);
+ if (l + ev->text_len < sizeof a->search_buf) {
+ memcpy(a->search_buf + l, ev->text, ev->text_len);
+ a->search_buf[l + ev->text_len] = 0;
+ /* Per keystroke: lib-vt keeps the results when the needle is merely
+ * resubmitted, so incremental typing costs a restart only when the
+ * needle actually changed -- which it did. */
+ pane_search_set(p, a->search_buf);
+ }
+ }
+ return true;
+}
+
static void drop_render_cb(node_t *n, void *ud) {
(void)ud;
if (n->pane) pane_render_cache_drop(n->pane);
@@ -1021,6 +1127,18 @@ void app_compose(app_t *a, screen_t *s) {
* clears it, not only the ones that thought to. */
if (cur(a)->focus) pane_clear_bell(cur(a)->focus->pane);
+ /* A search over a pane whose program keeps printing: catch the results up
+ * before the pane composes, so the highlight painted this frame is the
+ * highlight the counts describe. After the compose it would lag a frame --
+ * derive, then draw. */
+ if (a->searching) {
+ node_t *sn = pane_by_id(a, a->search_id);
+ if (sn && sn->pane)
+ pane_search_refresh(sn->pane);
+ else
+ search_end(a, false); /* the subject went away: no keyboard traps */
+ }
+
layout(a);
find_corners(a);
if (CFG.status_bar) draw_tab_strip(a, s);
diff --git a/src/config.c b/src/config.c
index 2e3e662..1b7971a 100644
--- a/src/config.c
+++ b/src/config.c
@@ -50,6 +50,7 @@ static const struct {
{"scroll-page-down", ACT_SCROLL_PAGE_DOWN},
{"scroll-top", ACT_SCROLL_TOP},
{"scroll-bottom", ACT_SCROLL_BOTTOM},
+ {"search", ACT_SEARCH},
{"new-tab", ACT_NEW_TAB},
{"next-tab", ACT_NEXT_TAB},
{"close-tab", ACT_CLOSE_TAB},
@@ -645,6 +646,10 @@ static const struct {
{"finder_bg", offsetof(config_t, finder_bg)},
{"finder_sel_fg", offsetof(config_t, finder_sel_fg)},
{"finder_sel_bg", offsetof(config_t, finder_sel_bg)},
+ {"search_fg", offsetof(config_t, search_fg)},
+ {"search_bg", offsetof(config_t, search_bg)},
+ {"search_cur_fg", offsetof(config_t, search_cur_fg)},
+ {"search_cur_bg", offsetof(config_t, search_cur_bg)},
{"bell", offsetof(config_t, bell)},
{"modal_fg", offsetof(config_t, modal_fg)},
{"modal_bg", offsetof(config_t, modal_bg)},
@@ -1022,6 +1027,16 @@ void config_defaults(config_t *c) {
c->finder_sel_fg = ink;
c->finder_sel_bg = accent;
+ /* Match highlighting reaches for the pager's convention rather than the
+ * accent: warm gold, because "found" is a different kind of thing from
+ * "selected" or "focused" and should not read as either. Every on-screen
+ * match gets the dim slab; the one the bar is on gets the bright gold with
+ * dark ink over it, so it is plainly the one ↑↓ and Enter act on. */
+ c->search_fg = bright;
+ c->search_bg = rgb(0x4d, 0x41, 0x24);
+ c->search_cur_fg = ink;
+ c->search_cur_bg = rgb(0xf2, 0xc9, 0x7a);
+
/* The two colours that mean something rather than match something: a bell
* is attention, so amber, not the accent -- painted in the focus colour it
* said nothing -- and a dead pane is warm red-orange. Both sit apart from
@@ -1070,12 +1085,15 @@ void config_defaults(config_t *c) {
bind_add(c, GHOSTTY_KEY_BACKSLASH, 0, ACT_SPLIT_COLS, false);
bind_add(c, GHOSTTY_KEY_MINUS, 0, ACT_SPLIT_ROWS, false);
bind_add(c, GHOSTTY_KEY_X, 0, ACT_CLOSE_PANE, false);
- /* `?` twice, because whether it arrives with shift depends on the outer
- * terminal: as a plain byte there is no modifier to be had, and under the
- * kitty keyboard protocol (which the client asks for) there is. Binding one
- * of them is a binding that works on the author's machine. */
+ /* `?` is help and `/` is search: the pair every pager taught. The two
+ * shared help for a while, because a legacy `?` used to decode with no
+ * modifier and the two keys could not be told apart. The decoder now reads
+ * the shift out of the character itself (`?` is shift+slash on the keyboard
+ * the legacy protocol assumes, the same inference a capital letter always
+ * got), so the pair can finally mean two things -- and the bare slash goes
+ * to the verb with the muscle memory. */
bind_add(c, GHOSTTY_KEY_SLASH, MOD_SHIFT, ACT_HELP, false);
- bind_add(c, GHOSTTY_KEY_SLASH, 0, ACT_HELP, false);
+ bind_add(c, GHOSTTY_KEY_SLASH, 0, ACT_SEARCH, false);
bind_add(c, GHOSTTY_KEY_R, 0, ACT_RERUN, false);
bind_add(c, GHOSTTY_KEY_Z, 0, ACT_ZOOM, false);
bind_add(c, GHOSTTY_KEY_E, 0, ACT_EDIT_CONFIG, false);
@@ -1256,6 +1274,7 @@ static const struct {
{ACT_SCROLL_PAGE_DOWN, "scroll", "down a page"},
{ACT_SCROLL_TOP, "scroll", "to the oldest line"},
{ACT_SCROLL_BOTTOM, "scroll", "back to the present"},
+ {ACT_SEARCH, "scroll", "search the scrollback"},
/* Their own group: a project is neither a pane nor this session, and the
* two verbs are the same pair -- go to one, write one down. */
diff --git a/src/input.c b/src/input.c
index 320b713..d6d5bbf 100644
--- a/src/input.c
+++ b/src/input.c
@@ -470,6 +470,25 @@ static size_t parse_one(input_parser_t *p, input_cb_t cb, void *ud) {
if (cp < 128 && cp >= 'A' && cp <= 'Z') mods |= MOD_SHIFT;
uint32_t unshifted = cp;
if (cp >= 'A' && cp <= 'Z') unshifted = cp + 32;
+ /* Shifted punctuation says shift the way a capital letter does. The legacy
+ * protocol reports no modifiers, so the character is the only witness --
+ * and without this `?` and `/` decode identically (SLASH, no mods), which
+ * makes them unbindable as two keys anywhere the kitty protocol is not.
+ * The kitty path never comes here; it carries real modifiers. The pane is
+ * unaffected either way: re-encoding to a legacy pane sends the text, and
+ * to a kitty pane says exactly what a real terminal would have. */
+ static const char SHIFT_PAIRS[][2] = {
+ {'~', '`'}, {'_', '-'}, {'+', '='}, {'{', '['}, {'}', ']'}, {'|', '\\'},
+ {':', ';'}, {'"', '\''}, {'<', ','}, {'>', '.'}, {'?', '/'}, {'!', '1'},
+ {'@', '2'}, {'#', '3'}, {'$', '4'}, {'%', '5'}, {'^', '6'}, {'&', '7'},
+ {'*', '8'}, {'(', '9'}, {')', '0'},
+ };
+ for (size_t i = 0; i < sizeof SHIFT_PAIRS / sizeof *SHIFT_PAIRS; i++)
+ if (cp == (uint32_t)SHIFT_PAIRS[i][0]) {
+ mods |= MOD_SHIFT;
+ unshifted = (uint32_t)SHIFT_PAIRS[i][1];
+ break;
+ }
emit_key(cb, ud, key, mods, KEY_PRESS, (const char *)b, ulen, unshifted);
return ulen;
}
diff --git a/src/pane.c b/src/pane.c
index 5447e0a..ecff425 100644
--- a/src/pane.c
+++ b/src/pane.c
@@ -90,6 +90,13 @@ struct pane {
bool selecting;
GhosttyTrackedGridRef sel_anchor;
+ /* Scrollback search (lib-vt's GhosttySearch). Created lazily on the first
+ * needle and kept for the pane's life: the search borrows the terminal and
+ * tracks its own state through resize, reflow and screen switches, which is
+ * exactly the state we do not want to rebuild per opening. NULL until then,
+ * so a pane that is never searched pays nothing. */
+ GhosttySearch search;
+
osc_scan_t scan;
char status[256];
pane_button_t buttons[8];
@@ -414,6 +421,7 @@ void pane_free(pane_t *p) {
if (p->kev) ghostty_key_event_free(p->kev);
if (p->kenc) ghostty_key_encoder_free(p->kenc);
ghostty_tracked_grid_ref_free(p->sel_anchor);
+ if (p->search) ghostty_search_free(p->search);
if (p->cells) ghostty_render_state_row_cells_free(p->cells);
if (p->rows) ghostty_render_state_row_iterator_free(p->rows);
if (p->rstate) ghostty_render_state_free(p->rstate);
@@ -1231,6 +1239,161 @@ void pane_scroll_pos(const pane_t *p, uint32_t *above, uint32_t *total) {
*total = (uint32_t)(sb.total > sb.len ? sb.total - sb.len : 0);
}
+/* ---- search ------------------------------------------------------------- *
+ *
+ * lib-vt owns the machinery (D4's pattern: the hard half is free): a
+ * GhosttySearch finds a needle in the active area and the scrollback of both
+ * screens, survives resize/reflow/alt-screen, and hands every match back as a
+ * GhosttySelection. What we own is when to feed it, which match is current,
+ * and -- because we tint every on-screen match ourselves rather than through
+ * the terminal's one selection -- where they all are (pane_search_marks).
+ */
+
+static bool ensure_search(pane_t *p) {
+ if (p->search) return true;
+ if (!p->term) return false;
+ return ghostty_search_new(NULL, &p->search, p->term) == GHOSTTY_SUCCESS;
+}
+
+/* Set (or clear) what the search is looking for, and land on the nearest
+ * match. Setting the same needle again is free -- lib-vt keeps the results --
+ * so incremental typing can resubmit per keystroke.
+ *
+ * The catch-up is ghostty_search_run(), which blocks until the scrollback has
+ * been walked. That is a deliberate simplicity: the default caps (10k lines,
+ * a 16MB ceiling) keep it in single milliseconds, and the incremental
+ * feed/tick split exists for the day a config raises them. */
+void pane_search_set(pane_t *p, const char *needle) {
+ if (!p || !ensure_search(p)) return;
+ if (needle && *needle) {
+ GhosttyString str = {.ptr = (const uint8_t *)needle, .len = strlen(needle)};
+ if (ghostty_search_set(p->search, GHOSTTY_SEARCH_OPT_NEEDLE, &str) !=
+ GHOSTTY_SUCCESS)
+ return;
+ ghostty_search_run(p->search);
+ /* From idle, the first "next" is the newest match: the bottom of the
+ * screen, nearest to where the eye already is. It also scrolls the
+ * viewport to the match (the select's own doing). With a selection
+ * already standing (the needle was resubmitted), it is kept, not
+ * advanced -- lib-vt treats an equal needle as the same search. */
+ size_t idx = 0;
+ if (ghostty_search_get(p->search, GHOSTTY_SEARCH_DATA_SELECTED_INDEX,
+ &idx) != GHOSTTY_SUCCESS)
+ ghostty_search_set(p->search, GHOSTTY_SEARCH_OPT_SELECT_NEXT, NULL);
+ } else {
+ ghostty_search_set(p->search, GHOSTTY_SEARCH_OPT_NEEDLE, NULL);
+ }
+ p->dirty = true;
+}
+
+/* Step the current match: `older` moves up into history (the direction a
+ * search from the prompt almost always wants), wrapping at either end. False
+ * when there is nothing to step to. The viewport follows the match on its own
+ * -- lib-vt's select scrolls it when the match is not already visible. */
+bool pane_search_step(pane_t *p, bool older) {
+ if (!p || !p->search) return false;
+ GhosttySearchOption opt =
+ older ? GHOSTTY_SEARCH_OPT_SELECT_NEXT : GHOSTTY_SEARCH_OPT_SELECT_PREV;
+ bool moved = ghostty_search_set(p->search, opt, NULL) == GHOSTTY_SUCCESS;
+ p->dirty = true;
+ return moved;
+}
+
+/* Catch the search up with a terminal that has kept printing. Called once per
+ * compose while a search is open: a feed with nothing new is cheap, and it is
+ * what keeps the counts and the on-screen match list current -- lib-vt learns
+ * of terminal changes only through a feed. */
+void pane_search_refresh(pane_t *p) {
+ if (!p || !p->search) return;
+ ghostty_search_run(p->search);
+ p->dirty = true;
+}
+
+/* Every match on the pages covering the viewport, in viewport cells, so a
+ * caller can tint them. A match that wraps a row is split into one span per
+ * row; a match straddling an edge whose start does not convert is skipped,
+ * which clips the list to what is visible (lib-vt's own renderer does the
+ * same). The one the bar is on is flagged `current`, found by matching the
+ * selected match's start cell -- both come from the same conversion, so the
+ * comparison is exact. */
+size_t pane_search_marks(const pane_t *p, pane_mark_t *out, size_t max) {
+ if (!p || !p->search || !p->term || !max) return 0;
+
+ bool have_cur = false;
+ uint16_t cur_x = 0, cur_y = 0;
+ GhosttySelection cur = GHOSTTY_INIT_SIZED(GhosttySelection);
+ if (ghostty_search_get(p->search, GHOSTTY_SEARCH_DATA_SELECTED_MATCH, &cur) ==
+ GHOSTTY_SUCCESS) {
+ GhosttyPointCoordinate pc;
+ if (ghostty_terminal_point_from_grid_ref(p->term, &cur.start,
+ GHOSTTY_POINT_TAG_VIEWPORT,
+ &pc) == GHOSTTY_SUCCESS) {
+ have_cur = true;
+ cur_x = pc.x;
+ cur_y = (uint16_t)pc.y;
+ }
+ }
+
+ GhosttySelection buf[256];
+ GhosttySelectionBuffer vb = {.ptr = buf, .cap = 256, .len = 0};
+ if (ghostty_search_get(p->search, GHOSTTY_SEARCH_DATA_VIEWPORT_MATCHES,
+ &vb) != GHOSTTY_SUCCESS)
+ return 0;
+
+ size_t n = 0;
+ size_t got = vb.len < 256 ? vb.len : 256;
+ for (size_t i = 0; i < got && n < max; i++) {
+ GhosttyPointCoordinate a, b;
+ if (ghostty_terminal_point_from_grid_ref(p->term, &buf[i].start,
+ GHOSTTY_POINT_TAG_VIEWPORT,
+ &a) != GHOSTTY_SUCCESS)
+ continue;
+ if (ghostty_terminal_point_from_grid_ref(p->term, &buf[i].end,
+ GHOSTTY_POINT_TAG_VIEWPORT,
+ &b) != GHOSTTY_SUCCESS)
+ continue;
+ /* A snapshot's end may precede its start in terminal order; a span wants
+ * them the other way round. */
+ if (b.y < a.y || (b.y == a.y && b.x < a.x)) {
+ GhosttyPointCoordinate t = a;
+ a = b;
+ b = t;
+ }
+ bool current = have_cur && a.x == cur_x && a.y == cur_y;
+ uint16_t last_col = (uint16_t)(p->cols ? p->cols - 1 : 0);
+ for (uint32_t row = a.y; row <= b.y && n < max; row++) {
+ if (row >= p->rows_n) break;
+ out[n].row = (uint16_t)row;
+ out[n].x0 = row == a.y ? (uint16_t)a.x : 0;
+ out[n].x1 = row == b.y ? (uint16_t)b.x : last_col;
+ out[n].current = current;
+ n++;
+ }
+ }
+ return n;
+}
+
+/* How the "k of n" reads: true with `idx` filled when a match is selected.
+ * `total` is filled either way, so "no matches" and "27 matches, none chosen"
+ * can be told apart. */
+bool pane_search_pos(const pane_t *p, size_t *idx, size_t *total) {
+ *idx = *total = 0;
+ if (!p || !p->search) return false;
+ ghostty_search_get(p->search, GHOSTTY_SEARCH_DATA_TOTAL_MATCHES, total);
+ return ghostty_search_get(p->search, GHOSTTY_SEARCH_DATA_SELECTED_INDEX,
+ idx) == GHOSTTY_SUCCESS;
+}
+
+/* Close: back to idle, which empties the match list and so takes every
+ * highlight with it. The GhosttySearch itself is kept -- it is small, and
+ * freeing it per close would rebuild tracked state on every reopening for no
+ * one's benefit. */
+void pane_search_close(pane_t *p) {
+ if (!p || !p->search) return;
+ ghostty_search_set(p->search, GHOSTTY_SEARCH_OPT_NEEDLE, NULL);
+ p->dirty = true;
+}
+
bool pane_line_empty(const pane_t *p) { return p && p->line_len == 0; }
bool pane_alt_screen(const pane_t *p) {