Something went wrong. Try again.
A GPU terminal emulator for Wayland and Windows on libghostty-vt, drawing with Vulkan, running a program on a pty or a serial port.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Searching a pane's terminal, its scrollback and all: what has been typed//! into the search field, and libghostty-vt's searcher for it.//!//! The searcher reads the terminal only when fed, with the terminal locked;//! between feeds it works through what it was fed with no lock at all. So//! the window feeds it a little at a time and lets it run, a turn of the//! loop at a time, and a long scrollback is searched without the terminal//! being held up or the window going unresponsive. It follows the program//! between the primary and alternate screens, and keeps up as output//! arrives.//!//! Matches are ordered from the newest, at the bottom, to the oldest: the//! next match is the one above, as a search back through history expects.
const std = @import("std");const Allocator = std.mem.Allocator;const vt = @import("ghostty-vt");
const Search = @This();
pub const Highlight = vt.highlight.Flattened;
gpa: Allocator,/// What has been typed.query: std.ArrayList(u8) = .empty,/// The searcher for `query`, once fed; none while the query is empty.searcher: ?vt.search.Terminal = null,/// The query changed since the searcher was made.changed: bool = false,/// The field is showing, and taking the typing. Closed, the search stays,/// for the next and previous match to go on from.open: bool = true,/// Closed, the selected match is shown: it was moved to since the field/// closed, and nothing has been typed to the program since.show_selected: bool = false,
/// How many of the searcher's steps to take a turn, between feeds.const steps = 64;
pub fn init(gpa: Allocator) Search { return .{ .gpa = gpa };}
/// With the terminal locked, or `t` null when it is already gone.pub fn deinit(s: *Search, t: ?*vt.Terminal) void { if (s.searcher) |*x| x.deinit(t); s.query.deinit(s.gpa); s.* = undefined;}
/// Adds `text` to the query.pub fn append(s: *Search, text: []const u8) Allocator.Error!void { if (text.len == 0) return; try s.query.appendSlice(s.gpa, text); s.changed = true;}
/// Adds pasted `text` to the query, made one line: each run of control/// characters — newlines, tabs — becomes a space, and one at either end/// goes, so that a pasted line with its newline searches for the line.pub fn paste(s: *Search, text: []const u8) Allocator.Error!void { const start = s.query.items.len; var gap = false; for (text) |c| { if (c < 0x20 or c == 0x7f) { gap = true; continue; } if (gap and s.query.items.len > start) try s.query.append(s.gpa, ' '); gap = false; try s.query.append(s.gpa, c); } if (s.query.items.len > start) s.changed = true;}
/// Takes the query's last character off.pub fn backspace(s: *Search) void { const q = s.query.items; if (q.len == 0) return; var i = q.len - 1; // Back to the first byte of the last UTF-8 sequence. while (i > 0 and q[i] & 0xc0 == 0x80) i -= 1; s.query.shrinkRetainingCapacity(i); s.changed = true;}
/// With the terminal locked: makes the searcher fit the query, and gives it/// what it needs of the terminal to go on — the screen as it is now, and/// the next stretch of scrollback.pub fn feed(s: *Search, t: *vt.Terminal) Allocator.Error!void { if (s.changed) { s.changed = false; if (s.searcher) |*x| x.deinit(t); s.searcher = null; if (s.query.items.len > 0) s.searcher = try .init(s.gpa, s.query.items); } const x = if (s.searcher) |*x| x else return; x.feed(t, true);}
/// Without the lock: makes what progress it can on what it was fed. True/// while it has more to do, whether it can do it alone or needs feeding.pub fn step(s: *Search) bool { if (s.changed) return true; const x = if (s.searcher) |*x| x else return false; for (0..steps) |_| switch (x.tick()) { .progress => {}, .blocked => return true, .complete => return false, }; return x.status() != .complete;}
/// Whether there is searching left to do.pub fn busy(s: *Search) bool { if (s.changed) return true; const x = if (s.searcher) |*x| x else return false; return x.status() != .complete;}
pub const Direction = enum { /// Up, to an older match. older, /// Down, to a newer one. newer,};
/// With the terminal locked: selects the next match `dir`, wrapping round,/// and scrolls the terminal to it if it is not in view. False with no/// match to select.pub fn select(s: *Search, t: *vt.Terminal, dir: Direction) Allocator.Error!bool { try s.feed(t); const x = if (s.searcher) |*x| x else return false; return x.select(t, switch (dir) { .older => .next, .newer => .prev, }, .if_needed);}
/// How many matches there are, as far as the search has got.pub fn total(s: *Search) usize { const x = if (s.searcher) |*x| x else return 0; const screen = x.activeScreenSearch() orelse return 0; return screen.matchesLen();}
/// Which match is selected, counted from the newest, at the bottom, as 1.pub fn position(s: *Search) ?usize { const x = if (s.searcher) |*x| x else return null; const screen = x.activeScreenSearch() orelse return null; const sel = screen.selected orelse return null; return sel.idx + 1;}
/// The matches in the terminal's viewport as of the last feed, and the/// selected one, wherever it is; for drawing.pub fn inView(s: *Search) []const Highlight { const x = if (s.searcher) |*x| x else return &.{}; return x.viewportMatches() catch &.{};}
pub fn selected(s: *Search) ?Highlight { const x = if (s.searcher) |*x| x else return null; const screen = x.activeScreenSearch() orelse return null; return screen.selectedMatch();}
/// Feeds and steps until done: for tests.fn finish(s: *Search, t: *vt.Terminal) !void { while (true) { try s.feed(t); if (!s.step()) break; }}
test "matches are found through the scrollback, newest first" { const gpa = std.testing.allocator; var t: vt.Terminal = try .init(std.testing.io, gpa, .{ .cols = 20, .rows = 5, .max_scrollback_bytes = 1024 * 1024 }); defer t.deinit(gpa); var stream: vt.TerminalStream = .init(.{ .handler = .init(&t), .allocator = gpa }); defer stream.deinit(); for (0..200) |i| { var buf: [32]u8 = undefined; stream.nextSlice(std.fmt.bufPrint(&buf, "{s} {d}\r\n", .{ if (i % 50 == 0) "needle" else "hay", i }) catch unreachable); }
var s: Search = .init(gpa); defer s.deinit(&t); try s.append("nee"); try s.append("dlx"); s.backspace(); try std.testing.expectEqualStrings("needl", s.query.items); try s.finish(&t); try std.testing.expect(!s.busy()); try std.testing.expectEqual(4, s.total()); try std.testing.expectEqual(null, s.position());
// The first is the newest; each next is older, and it wraps round. try std.testing.expect(try s.select(&t, .older)); try std.testing.expectEqual(1, s.position()); try std.testing.expect(try s.select(&t, .older)); try std.testing.expectEqual(2, s.position()); try std.testing.expect(try s.select(&t, .newer)); try std.testing.expectEqual(1, s.position()); // The oldest, at line 0, scrolled into view. for (0..3) |_| _ = try s.select(&t, .older); try std.testing.expectEqual(4, s.position()); try std.testing.expect(t.screens.active.pages.viewport != .active);}
test "an empty query searches for nothing" { const gpa = std.testing.allocator; var t: vt.Terminal = try .init(std.testing.io, gpa, .{ .cols = 20, .rows = 5 }); defer t.deinit(gpa); var s: Search = .init(gpa); defer s.deinit(&t); try s.append("x"); s.backspace(); try s.finish(&t); try std.testing.expectEqual(0, s.total()); try std.testing.expect(!try s.select(&t, .older));}
test "a paste is made one line" { var s: Search = .init(std.testing.allocator); defer s.deinit(null); try s.append("x "); try s.paste("\r\nfoo\tbar\n\nbaz\n"); try std.testing.expectEqualStrings("x foo bar baz", s.query.items); try std.testing.expect(s.changed);}
test "backspace takes off a whole character" { var s: Search = .init(std.testing.allocator); defer s.deinit(null); try s.append("aé"); s.backspace(); try std.testing.expectEqualStrings("a", s.query.items);}