diff options
| author | user <user@clank> | 2026-09-08 16:58:24 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-09-08 16:58:24 +0200 |
| commit | 935fb7e93726dcb8ab0529c2dbcb6b5e75656794 (patch) | |
| tree | 824b46e4cff68b7d4fa0c333717684ce273c9b10 /src | |
| parent | release: v0.1.6 paperwork -- formula and PKGBUILD pinned (diff) | |
| download | slosh-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.c | 17 | ||||
| -rw-r--r-- | src/app_draw.c | 95 | ||||
| -rw-r--r-- | src/app_ui.c | 118 | ||||
| -rw-r--r-- | src/config.c | 29 | ||||
| -rw-r--r-- | src/input.c | 19 | ||||
| -rw-r--r-- | src/pane.c | 163 |
6 files changed, 436 insertions, 5 deletions
@@ -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; } @@ -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) { |
