// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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); }