From 2ee1f3191e8f6e5c45d9f19fa09bb04c639fd4e1 Mon Sep 17 00:00:00 2001 From: Denys Zhak Date: Mon, 10 Nov 2025 16:53:27 +0100 Subject: [PATCH 001/209] feat: add descriptions to fish shell completions --- build.zig | 2 +- src/build/GhosttyResources.zig | 5 +++- src/extra/fish.zig | 49 ++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/build.zig b/build.zig index 68dc0028b..5fd611b6c 100644 --- a/build.zig +++ b/build.zig @@ -55,7 +55,7 @@ pub fn build(b: *std.Build) !void { ); // Ghostty resources like terminfo, shell integration, themes, etc. - const resources = try buildpkg.GhosttyResources.init(b, &config); + const resources = try buildpkg.GhosttyResources.init(b, &config, &deps); const i18n = if (config.i18n) try buildpkg.GhosttyI18n.init(b, &config) else null; // Ghostty executable, the actual runnable Ghostty program. diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index 1ac8fe2a9..a1bbe2857 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -6,10 +6,11 @@ const assert = std.debug.assert; const buildpkg = @import("main.zig"); const Config = @import("Config.zig"); const RunStep = std.Build.Step.Run; +const SharedDeps = @import("SharedDeps.zig"); steps: []*std.Build.Step, -pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { +pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !GhosttyResources { var steps: std.ArrayList(*std.Build.Step) = .empty; errdefer steps.deinit(b.allocator); @@ -26,6 +27,8 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { }); build_data_exe.linkLibC(); + deps.help_strings.addImport(build_data_exe); + // Terminfo terminfo: { const os_tag = cfg.target.result.os.tag; diff --git a/src/extra/fish.zig b/src/extra/fish.zig index 7ffc23093..2f00bca59 100644 --- a/src/extra/fish.zig +++ b/src/extra/fish.zig @@ -3,6 +3,7 @@ const std = @import("std"); const Config = @import("../config/Config.zig"); const Action = @import("../cli.zig").ghostty.Action; +const help_strings = @import("help_strings"); /// A fish completions configuration that contains all the available commands /// and options. @@ -81,6 +82,15 @@ fn writeCompletions(writer: *std.Io.Writer) !void { else => {}, } } + + if (@hasDecl(help_strings.Config, field.name)) { + const help = @field(help_strings.Config, field.name); + const desc = getDescription(help); + try writer.writeAll(" -d \""); + try writer.writeAll(desc); + try writer.writeAll("\""); + } + try writer.writeAll("\n"); } @@ -143,3 +153,42 @@ fn writeCompletions(writer: *std.Io.Writer) !void { } } } + +fn getDescription(comptime help: []const u8) []const u8 { + var out: [help.len * 2]u8 = undefined; + var len: usize = 0; + var prev_was_space = false; + + for (help, 0..) |c, i| { + switch (c) { + '.' => { + out[len] = '.'; + len += 1; + + if (i + 1 >= help.len) break; + const next = help[i + 1]; + if (next == ' ' or next == '\n') break; + }, + '\n' => { + if (!prev_was_space and len > 0) { + out[len] = ' '; + len += 1; + prev_was_space = true; + } + }, + '"' => { + out[len] = '\\'; + out[len + 1] = '"'; + len += 2; + prev_was_space = false; + }, + else => { + out[len] = c; + len += 1; + prev_was_space = (c == ' '); + }, + } + } + + return out[0..len]; +} From 011fc77caa5a12c5009577cdd1602da3ede6a8dd Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Sun, 16 Nov 2025 12:17:31 +0100 Subject: [PATCH 002/209] macOS: Fix dictation icon's position while speaking --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 063b13300..6e3597fd3 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1740,7 +1740,13 @@ extension Ghostty.SurfaceView: NSTextInputClient { } else { ghostty_surface_ime_point(surface, &x, &y, &width, &height) } - + if range.length == 0, width > 0 { + // This fixes #8493 while speaking + // My guess is that positive width doesn't make sense + // for the dictation microphone indicator + width = 0 + x += cellSize.width * Double(range.location + range.length) + } // Ghostty coordinates are in top-left (0, 0) so we have to convert to // bottom-left since that is what UIKit expects // when there's is no characters selected, From 0a7da32c7161c183db0bd0bdebafb4163e7c5d51 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 14 Nov 2025 15:29:35 -0700 Subject: [PATCH 003/209] fix: drop tmux control parsing immediately if broken --- src/terminal/tmux.zig | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index 67c5a979c..54cd7cdd5 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -33,7 +33,8 @@ pub const Client = struct { idle, /// We experienced unexpected input and are in a broken state - /// so we cannot continue processing. + /// so we cannot continue processing. When this state is set, + /// the buffer has been deinited and must not be accessed. broken, /// Inside an active notification (started with '%'). @@ -44,11 +45,21 @@ pub const Client = struct { }; pub fn deinit(self: *Client) void { + // If we're in a broken state, we already deinited + // the buffer, so we don't need to do anything. + if (self.state == .broken) return; + self.buffer.deinit(); } // Handle a byte of input. pub fn put(self: *Client, byte: u8) !?Notification { + // If we're in a broken state, just do nothing. + // + // We have to do this check here before we check the buffer, because if + // we're in a broken state then we'd have already deinited the buffer. + if (self.state == .broken) return null; + if (self.buffer.written().len >= self.max_bytes) { self.broken(); return error.OutOfMemory; From 712cc9e55c4dfc006c8e9767c07aee3af96dcbb3 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 14 Nov 2025 16:50:18 -0700 Subject: [PATCH 004/209] fix(shaper/coretext): handle non-monotonic runs by sorting --- src/font/shaper/coretext.zig | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index d73b191b8..45844d3e2 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -392,6 +392,12 @@ pub const Shaper = struct { self.cell_buf.clearRetainingCapacity(); try self.cell_buf.ensureTotalCapacity(self.alloc, line.getGlyphCount()); + // CoreText, despite our insistence with an enforced embedding level, + // may sometimes output runs that are non-monotonic. In order to fix + // this, we check the run status for each run and if any aren't ltr + // we set this to true, which indicates that we must sort our buffer. + var non_ltr: bool = false; + // CoreText may generate multiple runs even though our input to // CoreText is already split into runs by our own run iterator. // The runs as far as I can tell are always sequential to each @@ -401,6 +407,9 @@ pub const Shaper = struct { for (0..runs.getCount()) |i| { const ctrun = runs.getValueAtIndex(macos.text.Run, i); + const status = ctrun.getStatus(); + if (status.non_monotonic or status.right_to_left) non_ltr = true; + // Get our glyphs and positions const glyphs = ctrun.getGlyphsPtr() orelse try ctrun.getGlyphs(alloc); const advances = ctrun.getAdvancesPtr() orelse try ctrun.getAdvances(alloc); @@ -441,6 +450,25 @@ pub const Shaper = struct { } } + // If our buffer contains some non-ltr sections we need to sort it :/ + if (non_ltr) { + // This is EXCEPTIONALLY rare. Only happens for languages with + // complex shaping which we don't even really support properly + // right now, so are very unlikely to be used heavily by users + // of Ghostty. + @branchHint(.cold); + std.mem.sort( + font.shape.Cell, + self.cell_buf.items, + {}, + struct { + fn lt(_: void, a: font.shape.Cell, b: font.shape.Cell) bool { + return a.x < b.x; + } + }.lt, + ); + } + return self.cell_buf.items; } From 985e1a3ceaedc684dcc303bc205594c25279c5ab Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 14 Nov 2025 17:39:03 -0700 Subject: [PATCH 005/209] test(shaper/coretext): test non-monotonic CoreText output --- src/font/shaper/coretext.zig | 92 ++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 45844d3e2..c2cfb389c 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -1202,6 +1202,51 @@ test "shape Chinese characters" { try testing.expectEqual(@as(usize, 1), count); } +// This test exists because the string it uses causes CoreText to output a +// non-monotonic run, which we need to handle by sorting the resulting buffer. +test "shape Devanagari string" { + const testing = std.testing; + const alloc = testing.allocator; + + // We need a font that supports devanagari for this to work, if we can't + // find Arial Unicode MS, which is a system font on macOS, we just skip + // the test. + var testdata = testShaperWithDiscoveredFont( + alloc, + "Arial Unicode MS", + ) catch return error.SkipZigTest; + defer testdata.deinit(); + + // Make a screen with some data + var screen = try terminal.Screen.init(alloc, .{ .cols = 30, .rows = 3, .max_scrollback = 0 }); + defer screen.deinit(); + try screen.testWriteString("अपार्टमेंट"); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); + + const run = try it.next(alloc); + try testing.expect(run != null); + const cells = try shaper.shape(run.?); + + try testing.expectEqual(@as(usize, 8), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + try testing.expectEqual(@as(u16, 1), cells[1].x); + try testing.expectEqual(@as(u16, 2), cells[2].x); + try testing.expectEqual(@as(u16, 3), cells[3].x); + try testing.expectEqual(@as(u16, 4), cells[4].x); + try testing.expectEqual(@as(u16, 5), cells[5].x); + try testing.expectEqual(@as(u16, 5), cells[6].x); + try testing.expectEqual(@as(u16, 6), cells[7].x); + + try testing.expect(try it.next(alloc) == null); +} + test "shape box glyphs" { const testing = std.testing; const alloc = testing.allocator; @@ -1890,3 +1935,50 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { .lib = lib, }; } + +/// Return a fully initialized shaper by discovering a named font on the system. +fn testShaperWithDiscoveredFont(alloc: Allocator, font_req: [:0]const u8) !TestShaper { + var lib = try Library.init(alloc); + errdefer lib.deinit(); + + var c = Collection.init(); + c.load_options = .{ .library = lib }; + + // Discover and add our font to the collection. + { + var disco = font.Discover.init(); + defer disco.deinit(); + var disco_it = try disco.discover(alloc, .{ + .family = font_req, + .size = 12, + .monospace = false, + }); + defer disco_it.deinit(); + var face: font.DeferredFace = (try disco_it.next()).?; + errdefer face.deinit(); + _ = try c.add( + alloc, + try face.load(lib, .{ .size = .{ .points = 12 } }), + .{ + .style = .regular, + .fallback = false, + .size_adjustment = .none, + }, + ); + } + + const grid_ptr = try alloc.create(SharedGrid); + errdefer alloc.destroy(grid_ptr); + grid_ptr.* = try .init(alloc, .{ .collection = c }); + errdefer grid_ptr.*.deinit(alloc); + + var shaper = try Shaper.init(alloc, .{}); + errdefer shaper.deinit(); + + return TestShaper{ + .alloc = alloc, + .shaper = shaper, + .grid = grid_ptr, + .lib = lib, + }; +} From 00c2216fe1f976abcab11670691ecd6f78d8804e Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Sun, 16 Nov 2025 10:32:57 -0700 Subject: [PATCH 006/209] style: add Offset.Slice.slice helper fn Makes code that interacts with these so much cleaner --- src/Surface.zig | 2 +- src/renderer/link.zig | 6 +++--- src/terminal/Screen.zig | 4 ++-- src/terminal/hyperlink.zig | 20 ++++++++++---------- src/terminal/page.zig | 10 +++++----- src/terminal/size.zig | 5 +++++ 6 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 308b6d1f7..aa7902741 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4103,7 +4103,7 @@ fn osc8URI(self: *Surface, pin: terminal.Pin) ?[]const u8 { const cell = pin.rowAndCell().cell; const link_id = page.lookupHyperlink(cell) orelse return null; const entry = page.hyperlink_set.get(page.memory, link_id); - return entry.uri.offset.ptr(page.memory)[0..entry.uri.len]; + return entry.uri.slice(page.memory); } pub fn mousePressureCallback( diff --git a/src/renderer/link.zig b/src/renderer/link.zig index 39283cf5f..e16a85a68 100644 --- a/src/renderer/link.zig +++ b/src/renderer/link.zig @@ -131,7 +131,7 @@ pub const Set = struct { // then we use an alternate matching technique that iterates forward // and backward until it finds boundaries. if (link.id == .implicit) { - const uri = link.uri.offset.ptr(page.memory)[0..link.uri.len]; + const uri = link.uri.slice(page.memory); return try self.matchSetFromOSC8Implicit( alloc, matches, @@ -232,7 +232,7 @@ pub const Set = struct { if (link.id != .implicit) break; // If this link has a different URI then we found a boundary - const cell_uri = link.uri.offset.ptr(page.memory)[0..link.uri.len]; + const cell_uri = link.uri.slice(page.memory); if (!std.mem.eql(u8, uri, cell_uri)) break; sel.startPtr().* = cell_pin; @@ -258,7 +258,7 @@ pub const Set = struct { if (link.id != .implicit) break; // If this link has a different URI then we found a boundary - const cell_uri = link.uri.offset.ptr(page.memory)[0..link.uri.len]; + const cell_uri = link.uri.slice(page.memory); if (!std.mem.eql(u8, uri, cell_uri)) break; sel.endPtr().* = cell_pin; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 8ed256869..73992bf88 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1050,9 +1050,9 @@ pub fn cursorCopy(self: *Screen, other: Cursor, opts: struct { const other_page = &other.page_pin.node.data; const other_link = other_page.hyperlink_set.get(other_page.memory, other.hyperlink_id); - const uri = other_link.uri.offset.ptr(other_page.memory)[0..other_link.uri.len]; + const uri = other_link.uri.slice(other_page.memory); const id_ = switch (other_link.id) { - .explicit => |id| id.offset.ptr(other_page.memory)[0..id.len], + .explicit => |id| id.slice(other_page.memory), .implicit => null, }; diff --git a/src/terminal/hyperlink.zig b/src/terminal/hyperlink.zig index c608321b1..f0c2738b1 100644 --- a/src/terminal/hyperlink.zig +++ b/src/terminal/hyperlink.zig @@ -103,7 +103,7 @@ pub const PageEntry = struct { // Copy the URI { - const uri = self.uri.offset.ptr(self_page.memory)[0..self.uri.len]; + const uri = self.uri.slice(self_page.memory); const buf = try dst_page.string_alloc.alloc(u8, dst_page.memory, uri.len); @memcpy(buf, uri); copy.uri = .{ @@ -113,14 +113,14 @@ pub const PageEntry = struct { } errdefer dst_page.string_alloc.free( dst_page.memory, - copy.uri.offset.ptr(dst_page.memory)[0..copy.uri.len], + copy.uri.slice(dst_page.memory), ); // Copy the ID switch (copy.id) { .implicit => {}, // Shallow is fine .explicit => |slice| { - const id = slice.offset.ptr(self_page.memory)[0..slice.len]; + const id = slice.slice(self_page.memory); const buf = try dst_page.string_alloc.alloc(u8, dst_page.memory, id.len); @memcpy(buf, id); copy.id = .{ .explicit = .{ @@ -133,7 +133,7 @@ pub const PageEntry = struct { .implicit => {}, .explicit => |v| dst_page.string_alloc.free( dst_page.memory, - v.offset.ptr(dst_page.memory)[0..v.len], + v.slice(dst_page.memory), ), }; @@ -147,13 +147,13 @@ pub const PageEntry = struct { .implicit => |v| autoHash(&hasher, v), .explicit => |slice| autoHashStrat( &hasher, - slice.offset.ptr(base)[0..slice.len], + slice.slice(base), .Deep, ), } autoHashStrat( &hasher, - self.uri.offset.ptr(base)[0..self.uri.len], + self.uri.slice(base), .Deep, ); return hasher.final(); @@ -181,8 +181,8 @@ pub const PageEntry = struct { return std.mem.eql( u8, - self.uri.offset.ptr(self_base)[0..self.uri.len], - other.uri.offset.ptr(other_base)[0..other.uri.len], + self.uri.slice(self_base), + other.uri.slice(other_base), ); } @@ -196,12 +196,12 @@ pub const PageEntry = struct { .implicit => {}, .explicit => |v| alloc.free( page.memory, - v.offset.ptr(page.memory)[0..v.len], + v.slice(page.memory), ), } alloc.free( page.memory, - self.uri.offset.ptr(page.memory)[0..self.uri.len], + self.uri.slice(page.memory), ); } }; diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 5c83fc7c8..b13c625ed 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1198,7 +1198,7 @@ pub const Page = struct { }; errdefer self.string_alloc.free( self.memory, - page_uri.offset.ptr(self.memory)[0..page_uri.len], + page_uri.slice(self.memory), ); // Allocate an ID for our page memory if we have to. @@ -1228,7 +1228,7 @@ pub const Page = struct { .implicit => {}, .explicit => |slice| self.string_alloc.free( self.memory, - slice.offset.ptr(self.memory)[0..slice.len], + slice.slice(self.memory), ), }; @@ -1421,7 +1421,7 @@ pub const Page = struct { // most graphemes to fit within our chunk size. const cps = try self.grapheme_alloc.alloc(u21, self.memory, slice.len + 1); errdefer self.grapheme_alloc.free(self.memory, cps); - const old_cps = slice.offset.ptr(self.memory)[0..slice.len]; + const old_cps = slice.slice(self.memory); fastmem.copy(u21, cps[0..old_cps.len], old_cps); cps[slice.len] = cp; slice.* = .{ @@ -1440,7 +1440,7 @@ pub const Page = struct { const cell_offset = getOffset(Cell, self.memory, cell); const map = self.grapheme_map.map(self.memory); const slice = map.get(cell_offset) orelse return null; - return slice.offset.ptr(self.memory)[0..slice.len]; + return slice.slice(self.memory); } /// Move the graphemes from one cell to another. This can't fail @@ -1475,7 +1475,7 @@ pub const Page = struct { const entry = map.getEntry(cell_offset).?; // Free our grapheme data - const cps = entry.value_ptr.offset.ptr(self.memory)[0..entry.value_ptr.len]; + const cps = entry.value_ptr.slice(self.memory); self.grapheme_alloc.free(self.memory, cps); // Remove the entry diff --git a/src/terminal/size.zig b/src/terminal/size.zig index 8322ddb41..9c99f7732 100644 --- a/src/terminal/size.zig +++ b/src/terminal/size.zig @@ -28,6 +28,11 @@ pub fn Offset(comptime T: type) type { pub const Slice = struct { offset: Self = .{}, len: usize = 0, + + /// Returns a slice for the data, properly typed. + pub inline fn slice(self: Slice, base: anytype) []T { + return self.offset.ptr(base)[0..self.len]; + } }; /// Returns a pointer to the start of the data, properly typed. From bb2455b3fce9410114ebd391d144c60a9fa2677d Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Sun, 16 Nov 2025 11:27:59 -0700 Subject: [PATCH 007/209] fix(terminal/stream): handle executing C1 controls These can be unambiguously invoked in certain parser states, and as such we need to handle them. In real world use they are extremely rare, hence the branch hint. Without this, we get illegal behavior by trying to cast the value to the 7-bit C0 enum. --- src/terminal/stream.zig | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 23211fa80..de83dbe9c 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -660,6 +660,11 @@ pub fn Stream(comptime Handler: type) type { /// This function is abstracted this way to handle the case where /// the decoder emits a 0x1B after rejecting an ill-formed sequence. inline fn handleCodepoint(self: *Self, c: u21) !void { + // We need to increase the eval branch limit because a lot of + // tests end up running almost completely at comptime due to + // a chain of inline functions. + @setEvalBranchQuota(100_000); + if (c <= 0xF) { try self.execute(@intCast(c)); return; @@ -777,6 +782,18 @@ pub fn Stream(comptime Handler: type) type { } pub inline fn execute(self: *Self, c: u8) !void { + // If the character is > 0x7F, it's a C1 (8-bit) control, + // which is strictly equivalent to `ESC` plus `c - 0x40`. + if (c > 0x7F) { + @branchHint(.unlikely); + log.info("executing C1 0x{x} as ESC {c}", .{ c, c - 0x40 }); + try self.escDispatch(.{ + .intermediates = &.{}, + .final = c - 0x40, + }); + return; + } + const c0: ansi.C0 = @enumFromInt(c); if (comptime debug) log.info("execute: {f}", .{c0}); switch (c0) { From 9e44c9c956564a0e09fb402ea26edcbd12ebc834 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Sun, 16 Nov 2025 12:13:36 -0700 Subject: [PATCH 008/209] fix(terminal): avoid memory corruption in `cursorScrollDown` It was previously possible for `eraseRow` to move the cursor pin to a different page, and then the call to `cursorChangePin` would try to free the cursor style from that page even though that's not the page it belongs to, which creates memory corruption in release modes and integrity violations or assertions in debug mode. As a bonus, this should actually be faster this way than the old code, since it avoids needless work that `cursorChangePin` otherwise does. --- src/terminal/Screen.zig | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 73992bf88..126165a40 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -756,6 +756,11 @@ pub fn cursorDownScroll(self: *Screen) !void { var dirty = page.dirtyBitSet(); dirty.set(0); } else { + // The call to `eraseRow` will move the tracked cursor pin up by one + // row, but we don't actually want that, so we keep the old pin and + // put it back after calling `eraseRow`. + const old_pin = self.cursor.page_pin.*; + // eraseRow will shift everything below it up. try self.pages.eraseRow(.{ .active = .{} }); @@ -763,26 +768,15 @@ pub fn cursorDownScroll(self: *Screen) !void { // because eraseRow will mark all the rotated rows as dirty // in the entire page. - // We need to move our cursor down one because eraseRows will - // preserve our pin directly and we're erasing one row. - const page_pin = self.cursor.page_pin.down(1).?; - self.cursorChangePin(page_pin); - const page_rac = page_pin.rowAndCell(); + // We don't use `cursorChangePin` here because we aren't + // actually changing the pin, we're keeping it the same. + self.cursor.page_pin.* = old_pin; + + // We do, however, need to refresh the cached page row + // and cell, because `eraseRow` will have moved the row. + const page_rac = self.cursor.page_pin.rowAndCell(); self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell; - - // The above may clear our cursor so we need to update that - // again. If this fails (highly unlikely) we just reset - // the cursor. - self.manualStyleUpdate() catch |err| { - // This failure should not happen because manualStyleUpdate - // handles page splitting, overflow, and more. This should only - // happen if we're out of RAM. In this case, we'll just degrade - // gracefully back to the default style. - log.err("failed to update style on cursor scroll err={}", .{err}); - self.cursor.style = .{}; - self.cursor.style_id = 0; - }; } } else { const old_pin = self.cursor.page_pin.*; From c5ada505af4de8b1c8cb0192df321ddccf00a81e Mon Sep 17 00:00:00 2001 From: Denys Zhak Date: Mon, 17 Nov 2025 01:22:33 +0100 Subject: [PATCH 009/209] feat: add test for getDescription --- src/extra/fish.zig | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/extra/fish.zig b/src/extra/fish.zig index 2f00bca59..1419fde5f 100644 --- a/src/extra/fish.zig +++ b/src/extra/fish.zig @@ -192,3 +192,15 @@ fn getDescription(comptime help: []const u8) []const u8 { return out[0..len]; } + +test "getDescription" { + const input = "First sentence with \"quotes\"\nand newlines. Second sentence."; + const expected = "First sentence with \\\"quotes\\\" and newlines."; + const result = comptime getDescription(input); + + comptime { + if (!std.mem.eql(u8, result, expected)) { + @compileError("getDescription test failed: expected '" ++ expected ++ "' but got '" ++ result ++ "'"); + } + } +} From 995a7377c1deb478ae993e496ad5954ad78d9579 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Sun, 16 Nov 2025 15:48:17 -0700 Subject: [PATCH 010/209] fix(terminal): avoid lockup caused by 0-length hyperlink This could cause a 0-length hyperlink to be present in the screen, which, in ReleaseFast, causes a lockup as the string alloc tries to iterate `1..0` to allocate 0 chunks. --- src/terminal/Screen.zig | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 126165a40..09e957786 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2146,7 +2146,13 @@ pub fn cursorSetHyperlink(self: *Screen) !void { ); // Retry - return try self.cursorSetHyperlink(); + // + // We check that the cursor hyperlink hasn't been destroyed + // by the capacity adjustment first though- since despite the + // terrible code above, that can still apparently happen ._. + if (self.cursor.hyperlink_id > 0) { + return try self.cursorSetHyperlink(); + } }, } } From 243d32c82a050fd356371d2122184b974b6e9ebd Mon Sep 17 00:00:00 2001 From: Jake Nelson Date: Mon, 17 Nov 2025 15:58:50 +1100 Subject: [PATCH 011/209] fix: ColorList.clone not cloning colors_c --- src/config/Config.zig | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 6469c333e..655fd0cc0 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -5203,6 +5203,7 @@ pub const ColorList = struct { ) Allocator.Error!Self { return .{ .colors = try self.colors.clone(alloc), + .colors_c = try self.colors_c.clone(alloc), }; } @@ -5281,6 +5282,26 @@ pub const ColorList = struct { try p.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); try std.testing.expectEqualSlices(u8, "a = #000000,#ffffff\n", buf.written()); } + + test "clone" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var source: Self = .{}; + try source.parseCLI(alloc, "#ff0000,#00ff00,#0000ff"); + + const cloned = try source.clone(alloc); + + try testing.expect(source.equal(cloned)); + try testing.expectEqual(source.colors_c.items.len, cloned.colors_c.items.len); + for (source.colors_c.items, cloned.colors_c.items) |src_c, clone_c| { + try testing.expectEqual(src_c.r, clone_c.r); + try testing.expectEqual(src_c.g, clone_c.g); + try testing.expectEqual(src_c.b, clone_c.b); + } + } }; /// Palette is the 256 color palette for 256-color mode. This is still From 9a46397b593f6394726a62e3268640d10dd9e696 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 17 Nov 2025 04:55:38 -1000 Subject: [PATCH 012/209] benchmark: screen clone --- src/benchmark/ScreenClone.zig | 166 ++++++++++++++++++++++++++++++++++ src/benchmark/cli.zig | 2 + src/benchmark/main.zig | 1 + 3 files changed, 169 insertions(+) create mode 100644 src/benchmark/ScreenClone.zig diff --git a/src/benchmark/ScreenClone.zig b/src/benchmark/ScreenClone.zig new file mode 100644 index 000000000..df36f1813 --- /dev/null +++ b/src/benchmark/ScreenClone.zig @@ -0,0 +1,166 @@ +//! This benchmark tests the performance of the Screen.clone +//! function. This is useful because it is one of the primary lock +//! holders that impact IO performance when the renderer is active. +//! We do this very frequently. +const ScreenClone = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const terminalpkg = @import("../terminal/main.zig"); +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const Terminal = terminalpkg.Terminal; + +const log = std.log.scoped(.@"terminal-stream-bench"); + +opts: Options, +terminal: Terminal, + +pub const Options = struct { + /// The type of codepoint width calculation to use. + mode: Mode = .clone, + + /// The size of the terminal. This affects benchmarking when + /// dealing with soft line wrapping and the memory impact + /// of page sizes. + @"terminal-rows": u16 = 80, + @"terminal-cols": u16 = 120, + + /// The data to read as a filepath. If this is "-" then + /// we will read stdin. If this is unset, then we will + /// do nothing (benchmark is a noop). It'd be more unixy to + /// use stdin by default but I find that a hanging CLI command + /// with no interaction is a bit annoying. + /// + /// This will be used to initialize the terminal screen state before + /// cloning. This data can switch to alt screen if it wants. The time + /// to read this is not part of the benchmark. + data: ?[]const u8 = null, +}; + +pub const Mode = enum { + /// The baseline mode copies the screen by value. + noop, + + /// Full clone + clone, +}; + +pub fn create( + alloc: Allocator, + opts: Options, +) !*ScreenClone { + const ptr = try alloc.create(ScreenClone); + errdefer alloc.destroy(ptr); + + ptr.* = .{ + .opts = opts, + .terminal = try .init(alloc, .{ + .rows = opts.@"terminal-rows", + .cols = opts.@"terminal-cols", + }), + }; + + return ptr; +} + +pub fn destroy(self: *ScreenClone, alloc: Allocator) void { + self.terminal.deinit(alloc); + alloc.destroy(self); +} + +pub fn benchmark(self: *ScreenClone) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .noop => stepNoop, + .clone => stepClone, + }, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScreenClone = @ptrCast(@alignCast(ptr)); + + // Always reset our terminal state + self.terminal.fullReset(); + + // Setup our terminal state + const data_f: std.fs.File = (options.dataFile( + self.opts.data, + ) catch |err| { + log.warn("error opening data file err={}", .{err}); + return error.BenchmarkFailed; + }) orelse return; + + var stream = self.terminal.vtStream(); + defer stream.deinit(); + + var read_buf: [4096]u8 = undefined; + var f_reader = data_f.reader(&read_buf); + const r = &f_reader.interface; + + var buf: [4096]u8 = undefined; + while (true) { + const n = r.readSliceShort(&buf) catch { + log.warn("error reading data file err={?}", .{f_reader.err}); + return error.BenchmarkFailed; + }; + if (n == 0) break; // EOF reached + stream.nextSlice(buf[0..n]) catch |err| { + log.warn("error processing data file chunk err={}", .{err}); + return error.BenchmarkFailed; + }; + } +} + +fn teardown(ptr: *anyopaque) void { + const self: *ScreenClone = @ptrCast(@alignCast(ptr)); + _ = self; +} + +fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScreenClone = @ptrCast(@alignCast(ptr)); + + // We loop because its so fast that a single benchmark run doesn't + // properly capture our speeds. + for (0..1000) |_| { + const s: terminalpkg.Screen = self.terminal.screens.active.*; + std.mem.doNotOptimizeAway(s); + } +} + +fn stepClone(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScreenClone = @ptrCast(@alignCast(ptr)); + + // We loop because its so fast that a single benchmark run doesn't + // properly capture our speeds. + for (0..1000) |_| { + const s: *terminalpkg.Screen = self.terminal.screens.active; + const copy = s.clone( + s.alloc, + .{ .viewport = .{} }, + null, + ) catch |err| { + log.warn("error cloning screen err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(copy); + + // Note: we purposely do not free memory because we don't want + // to benchmark that. We'll free when the benchmark exits. + } +} + +test ScreenClone { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *ScreenClone = try .create(alloc, .{}); + defer impl.destroy(alloc); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig index 3b1c905eb..816ecd3f6 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -8,6 +8,7 @@ const cli = @import("../cli.zig"); pub const Action = enum { @"codepoint-width", @"grapheme-break", + @"screen-clone", @"terminal-parser", @"terminal-stream", @"is-symbol", @@ -22,6 +23,7 @@ pub const Action = enum { /// See TerminalStream for an example. pub fn Struct(comptime action: Action) type { return switch (action) { + .@"screen-clone" => @import("ScreenClone.zig"), .@"terminal-stream" => @import("TerminalStream.zig"), .@"codepoint-width" => @import("CodepointWidth.zig"), .@"grapheme-break" => @import("GraphemeBreak.zig"), diff --git a/src/benchmark/main.zig b/src/benchmark/main.zig index 3a59125fc..5673044f2 100644 --- a/src/benchmark/main.zig +++ b/src/benchmark/main.zig @@ -4,6 +4,7 @@ pub const CApi = @import("CApi.zig"); pub const TerminalStream = @import("TerminalStream.zig"); pub const CodepointWidth = @import("CodepointWidth.zig"); pub const GraphemeBreak = @import("GraphemeBreak.zig"); +pub const ScreenClone = @import("ScreenClone.zig"); pub const TerminalParser = @import("TerminalParser.zig"); pub const IsSymbol = @import("IsSymbol.zig"); From 09d41fd4af5d7f69bacab90a31da44ae87717122 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 17 Nov 2025 06:10:33 -1000 Subject: [PATCH 013/209] terminal: page tests for full clone --- src/terminal/page.zig | 78 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 5c83fc7c8..87adbfedf 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -2233,6 +2233,84 @@ test "Page clone" { } } +test "Page clone graphemes" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Append some graphemes + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .init(0x09); + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + try page.appendGrapheme(rac.row, rac.cell, 0x0B); + } + + // Clone it + var page2 = try page.clone(); + defer page2.deinit(); + { + const rac = page2.getRowAndCell(0, 0); + try testing.expect(rac.row.grapheme); + try testing.expect(rac.cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{ 0x0A, 0x0B }, page2.lookupGrapheme(rac.cell).?); + } +} + +test "Page clone styles" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write with some styles + { + const id = try page.styles.add(page.memory, .{ .flags = .{ + .bold = true, + } }); + + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.row.styled = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + .style_id = id, + }; + page.styles.use(page.memory, id); + } + } + + // Clone it + var page2 = try page.clone(); + defer page2.deinit(); + { + const id: u16 = style: { + const rac = page2.getRowAndCell(0, 0); + break :style rac.cell.style_id; + }; + + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, 0); + try testing.expect(rac.row.styled); + try testing.expectEqual(id, rac.cell.style_id); + } + + const style = page.styles.get( + page.memory, + id, + ); + try testing.expect((Style{ .flags = .{ + .bold = true, + } }).eql(style.*)); + } +} + test "Page cloneFrom" { var page = try Page.init(.{ .cols = 10, From 2f49e0c90200e7e2231fa0958df78e0ea2efbe0e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 17 Nov 2025 06:41:59 -1000 Subject: [PATCH 014/209] remove screenclone test cause it leaks memory on purpose --- src/benchmark/ScreenClone.zig | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/benchmark/ScreenClone.zig b/src/benchmark/ScreenClone.zig index df36f1813..942b08cd1 100644 --- a/src/benchmark/ScreenClone.zig +++ b/src/benchmark/ScreenClone.zig @@ -153,14 +153,3 @@ fn stepClone(ptr: *anyopaque) Benchmark.Error!void { // to benchmark that. We'll free when the benchmark exits. } } - -test ScreenClone { - const testing = std.testing; - const alloc = testing.allocator; - - const impl: *ScreenClone = try .create(alloc, .{}); - defer impl.destroy(alloc); - - const bench = impl.benchmark(); - _ = try bench.run(.once); -} From 2f1427f5290724f2ec37417976bdf82fa50a9e6c Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Mon, 17 Nov 2025 09:21:30 +0100 Subject: [PATCH 015/209] =?UTF-8?q?macOS:=20match=20scroller=E2=80=99s=20a?= =?UTF-8?q?ppearance=20with=20surface=E2=80=99s=20background?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- macos/Sources/Ghostty/SurfaceScrollView.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index 86ec355fa..4e81eda14 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -172,13 +172,16 @@ class SurfaceScrollView: NSView { } // MARK: Scrolling - + private func synchronizeAppearance() { let scrollbarConfig = surfaceView.derivedConfig.scrollbar scrollView.hasVerticalScroller = scrollbarConfig != .never scrollView.verticalScroller?.controlSize = .small + let hasLightBackground = OSColor(surfaceView.derivedConfig.backgroundColor).isLightColor + // Make sure the scroller’s appearance matches the surface's background color. + scrollView.appearance = NSAppearance(named: hasLightBackground ? .aqua : .darkAqua) } - + /// Positions the surface view to fill the currently visible rectangle. /// /// This is called whenever the scroll position changes. The surface view (which does the From 6d5b4a34264b0dd84cf513f296410d20bc27882c Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 17 Nov 2025 12:13:56 -0700 Subject: [PATCH 016/209] perf: replace `std.debug.assert` with inlined version See doc comment in `quirks.zig` for reasoning --- src/App.zig | 2 +- src/Surface.zig | 2 +- src/apprt/action.zig | 2 +- src/apprt/embedded.zig | 2 +- src/apprt/gtk/cgroup.zig | 2 +- src/apprt/gtk/class/application.zig | 2 +- .../gtk/class/clipboard_confirmation_dialog.zig | 2 +- src/apprt/gtk/class/global_shortcuts.zig | 2 +- src/apprt/gtk/class/imgui_widget.zig | 2 +- src/apprt/gtk/class/resize_overlay.zig | 2 +- src/apprt/gtk/class/split_tree.zig | 2 +- src/apprt/gtk/class/surface.zig | 2 +- src/apprt/gtk/class/surface_child_exited.zig | 2 +- src/apprt/gtk/class/surface_scrolled_window.zig | 2 +- src/apprt/gtk/class/tab.zig | 2 +- src/apprt/gtk/class/window.zig | 2 +- src/apprt/gtk/ext.zig | 2 +- src/apprt/gtk/ext/actions.zig | 2 +- src/apprt/ipc.zig | 2 +- src/cli/args.zig | 2 +- src/cli/diagnostics.zig | 2 +- src/cli/edit_config.zig | 2 +- src/cli/ssh-cache/DiskCache.zig | 2 +- src/config/CApi.zig | 2 +- src/config/ClipboardCodepointMap.zig | 2 +- src/config/Config.zig | 2 +- src/config/conditional.zig | 2 +- src/config/edit.zig | 2 +- src/config/file_load.zig | 2 +- src/config/io.zig | 2 +- src/config/path.zig | 2 +- src/config/theme.zig | 2 +- src/datastruct/blocking_queue.zig | 2 +- src/datastruct/cache_table.zig | 2 +- src/datastruct/circ_buf.zig | 2 +- src/datastruct/lru.zig | 2 +- src/datastruct/segmented_pool.zig | 2 +- src/datastruct/split_tree.zig | 2 +- src/fastmem.zig | 1 - src/font/Atlas.zig | 2 +- src/font/CodepointMap.zig | 2 +- src/font/Collection.zig | 2 +- src/font/DeferredFace.zig | 2 +- src/font/SharedGrid.zig | 2 +- src/font/SharedGridSet.zig | 2 +- src/font/discovery.zig | 2 +- src/font/face/coretext.zig | 2 +- src/font/face/freetype.zig | 2 +- src/font/face/web_canvas.zig | 2 +- src/font/opentype/head.zig | 2 +- src/font/opentype/hhea.zig | 2 +- src/font/opentype/os2.zig | 2 +- src/font/opentype/post.zig | 2 +- src/font/opentype/sfnt.zig | 2 +- src/font/opentype/svg.zig | 2 +- src/font/shaper/Cache.zig | 2 +- src/font/shaper/coretext.zig | 2 +- src/font/shaper/feature.zig | 2 +- src/font/shaper/harfbuzz.zig | 2 +- src/font/shaper/noop.zig | 2 +- src/font/shaper/run.zig | 2 +- src/font/shaper/web_canvas.zig | 2 +- src/font/sprite/Face.zig | 2 +- src/font/sprite/canvas.zig | 2 +- src/font/sprite/draw/block.zig | 2 +- src/font/sprite/draw/box.zig | 2 +- src/font/sprite/draw/braille.zig | 2 +- src/font/sprite/draw/branch.zig | 2 +- src/font/sprite/draw/common.zig | 2 +- src/font/sprite/draw/special.zig | 2 +- .../draw/symbols_for_legacy_computing.zig | 2 +- .../symbols_for_legacy_computing_supplement.zig | 2 +- src/input/Binding.zig | 2 +- src/input/command.zig | 2 +- src/input/paste.zig | 2 +- src/inspector/Inspector.zig | 2 +- src/inspector/cell.zig | 2 +- src/inspector/page.zig | 2 +- src/lib/union.zig | 2 +- src/main_c.zig | 2 +- src/os/args.zig | 2 +- src/os/cgroup.zig | 2 +- src/os/flatpak.zig | 2 +- src/os/homedir.zig | 2 +- src/os/locale.zig | 2 +- src/os/macos.zig | 2 +- src/os/mouse.zig | 2 +- src/os/xdg.zig | 2 +- src/quirks.zig | 17 +++++++++++++++++ src/renderer/Metal.zig | 2 +- src/renderer/OpenGL.zig | 2 +- src/renderer/Thread.zig | 2 +- src/renderer/cell.zig | 2 +- src/renderer/generic.zig | 2 +- src/renderer/image.zig | 2 +- src/renderer/message.zig | 2 +- src/renderer/metal/Frame.zig | 2 +- src/renderer/metal/IOSurfaceLayer.zig | 2 +- src/renderer/metal/Pipeline.zig | 2 +- src/renderer/metal/RenderPass.zig | 2 +- src/renderer/metal/Sampler.zig | 2 +- src/renderer/metal/Target.zig | 2 +- src/renderer/metal/Texture.zig | 2 +- src/renderer/metal/buffer.zig | 2 +- src/renderer/metal/shaders.zig | 2 +- src/renderer/opengl/Frame.zig | 2 +- src/renderer/opengl/Pipeline.zig | 2 +- src/renderer/opengl/RenderPass.zig | 2 +- src/renderer/opengl/Sampler.zig | 2 +- src/renderer/opengl/Target.zig | 2 +- src/renderer/opengl/Texture.zig | 2 +- src/renderer/opengl/buffer.zig | 2 +- src/renderer/opengl/shaders.zig | 2 +- src/renderer/shadertoy.zig | 2 +- src/simd/base64.zig | 2 +- src/simd/base64_scalar.zig | 2 +- src/simd/vt.zig | 2 +- src/terminal/PageList.zig | 2 +- src/terminal/Parser.zig | 2 +- src/terminal/Screen.zig | 2 +- src/terminal/ScreenSet.zig | 2 +- src/terminal/Selection.zig | 2 +- src/terminal/Tabstops.zig | 2 +- src/terminal/Terminal.zig | 2 +- src/terminal/apc.zig | 2 +- src/terminal/bitmap_allocator.zig | 2 +- src/terminal/c/key_encode.zig | 2 +- src/terminal/c/key_event.zig | 2 +- src/terminal/c/osc.zig | 2 +- src/terminal/c/sgr.zig | 2 +- src/terminal/charsets.zig | 2 +- src/terminal/color.zig | 2 +- src/terminal/dcs.zig | 2 +- src/terminal/formatter.zig | 2 +- src/terminal/hash_map.zig | 2 +- src/terminal/hyperlink.zig | 2 +- src/terminal/kitty/graphics_command.zig | 2 +- src/terminal/kitty/graphics_exec.zig | 2 +- src/terminal/kitty/graphics_image.zig | 2 +- src/terminal/kitty/graphics_render.zig | 2 +- src/terminal/kitty/graphics_storage.zig | 2 +- src/terminal/kitty/graphics_unicode.zig | 2 +- src/terminal/osc.zig | 2 +- src/terminal/page.zig | 2 +- src/terminal/point.zig | 2 +- src/terminal/ref_counted_set.zig | 2 +- src/terminal/search/pagelist.zig | 2 +- src/terminal/search/screen.zig | 2 +- src/terminal/search/sliding_window.zig | 2 +- src/terminal/search/viewport.zig | 2 +- src/terminal/sgr.zig | 2 +- src/terminal/size.zig | 2 +- src/terminal/stream.zig | 2 +- src/terminal/style.zig | 2 +- src/terminal/tmux.zig | 2 +- src/terminal/x11_color.zig | 2 +- src/termio/Exec.zig | 2 +- src/termio/Termio.zig | 2 +- src/termio/backend.zig | 2 +- src/termio/mailbox.zig | 2 +- src/termio/message.zig | 2 +- src/termio/stream_handler.zig | 2 +- 162 files changed, 177 insertions(+), 161 deletions(-) diff --git a/src/App.zig b/src/App.zig index 69667dcb9..2fae4d7df 100644 --- a/src/App.zig +++ b/src/App.zig @@ -5,7 +5,7 @@ const App = @This(); const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const build_config = @import("build_config.zig"); const apprt = @import("apprt.zig"); diff --git a/src/Surface.zig b/src/Surface.zig index aa7902741..63af42680 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -17,7 +17,7 @@ pub const Message = apprt.surface.Message; const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const global_state = &@import("global.zig").state; diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 1c286e98d..11186f059 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -1,6 +1,6 @@ const std = @import("std"); const build_config = @import("../build_config.zig"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const apprt = @import("../apprt.zig"); const configpkg = @import("../config.zig"); const input = @import("../input.zig"); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 25d09271e..da7a585a5 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -6,7 +6,7 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const objc = @import("objc"); const apprt = @import("../apprt.zig"); diff --git a/src/apprt/gtk/cgroup.zig b/src/apprt/gtk/cgroup.zig index 697126798..dbf11a287 100644 --- a/src/apprt/gtk/cgroup.zig +++ b/src/apprt/gtk/cgroup.zig @@ -1,7 +1,7 @@ /// Contains all the logic for putting the Ghostty process and /// each individual surface into its own cgroup. const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const gio = @import("gio"); diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 2f0a7c5c3..eac88f9cf 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const adw = @import("adw"); diff --git a/src/apprt/gtk/class/clipboard_confirmation_dialog.zig b/src/apprt/gtk/class/clipboard_confirmation_dialog.zig index d3d1b30b1..4bcc8696a 100644 --- a/src/apprt/gtk/class/clipboard_confirmation_dialog.zig +++ b/src/apprt/gtk/class/clipboard_confirmation_dialog.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const adw = @import("adw"); const glib = @import("glib"); const gobject = @import("gobject"); diff --git a/src/apprt/gtk/class/global_shortcuts.zig b/src/apprt/gtk/class/global_shortcuts.zig index 9c67be7c1..e5d89003a 100644 --- a/src/apprt/gtk/class/global_shortcuts.zig +++ b/src/apprt/gtk/class/global_shortcuts.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const adw = @import("adw"); const gio = @import("gio"); diff --git a/src/apprt/gtk/class/imgui_widget.zig b/src/apprt/gtk/class/imgui_widget.zig index 854dec20b..ef1ca05c9 100644 --- a/src/apprt/gtk/class/imgui_widget.zig +++ b/src/apprt/gtk/class/imgui_widget.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const cimgui = @import("cimgui"); const gl = @import("opengl"); diff --git a/src/apprt/gtk/class/resize_overlay.zig b/src/apprt/gtk/class/resize_overlay.zig index f6e0c1442..e13dcbc5d 100644 --- a/src/apprt/gtk/class/resize_overlay.zig +++ b/src/apprt/gtk/class/resize_overlay.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const adw = @import("adw"); const glib = @import("glib"); const gobject = @import("gobject"); diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 1c901b1bb..4fbf7a0c2 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -1,6 +1,6 @@ const std = @import("std"); const build_config = @import("../../../build_config.zig"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const adw = @import("adw"); const gio = @import("gio"); diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 6b29c3e12..291a405ce 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const adw = @import("adw"); const gdk = @import("gdk"); diff --git a/src/apprt/gtk/class/surface_child_exited.zig b/src/apprt/gtk/class/surface_child_exited.zig index bdee81397..4e34f3340 100644 --- a/src/apprt/gtk/class/surface_child_exited.zig +++ b/src/apprt/gtk/class/surface_child_exited.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const adw = @import("adw"); const glib = @import("glib"); const gobject = @import("gobject"); diff --git a/src/apprt/gtk/class/surface_scrolled_window.zig b/src/apprt/gtk/class/surface_scrolled_window.zig index 3095b4c78..505b16dda 100644 --- a/src/apprt/gtk/class/surface_scrolled_window.zig +++ b/src/apprt/gtk/class/surface_scrolled_window.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const adw = @import("adw"); const gobject = @import("gobject"); const gtk = @import("gtk"); diff --git a/src/apprt/gtk/class/tab.zig b/src/apprt/gtk/class/tab.zig index c9928be8b..d7a82b776 100644 --- a/src/apprt/gtk/class/tab.zig +++ b/src/apprt/gtk/class/tab.zig @@ -1,6 +1,6 @@ const std = @import("std"); const build_config = @import("../../../build_config.zig"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const adw = @import("adw"); const gio = @import("gio"); const glib = @import("glib"); diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index 8c79d6b75..dbcf0fcd1 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -1,6 +1,6 @@ const std = @import("std"); const build_config = @import("../../../build_config.zig"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const adw = @import("adw"); const gdk = @import("gdk"); const gio = @import("gio"); diff --git a/src/apprt/gtk/ext.zig b/src/apprt/gtk/ext.zig index 18587d9ca..f832d1f90 100644 --- a/src/apprt/gtk/ext.zig +++ b/src/apprt/gtk/ext.zig @@ -4,7 +4,7 @@ //! helpers. const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const gio = @import("gio"); diff --git a/src/apprt/gtk/ext/actions.zig b/src/apprt/gtk/ext/actions.zig index 344c08e05..3232bc18b 100644 --- a/src/apprt/gtk/ext/actions.zig +++ b/src/apprt/gtk/ext/actions.zig @@ -1,6 +1,6 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const testing = std.testing; const gio = @import("gio"); diff --git a/src/apprt/ipc.zig b/src/apprt/ipc.zig index 6be8bdf07..a6e8412e0 100644 --- a/src/apprt/ipc.zig +++ b/src/apprt/ipc.zig @@ -2,7 +2,7 @@ //! process. const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; pub const Errors = error{ /// The IPC failed. If a function returns this error, it's expected that diff --git a/src/cli/args.zig b/src/cli/args.zig index 76026fbf2..43a15ca06 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -1,6 +1,6 @@ const std = @import("std"); const mem = std.mem; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const diags = @import("diagnostics.zig"); diff --git a/src/cli/diagnostics.zig b/src/cli/diagnostics.zig index 2af8bb4f8..7f4dcc45e 100644 --- a/src/cli/diagnostics.zig +++ b/src/cli/diagnostics.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const build_config = @import("../build_config.zig"); diff --git a/src/cli/edit_config.zig b/src/cli/edit_config.zig index 37f961a44..056aecc0d 100644 --- a/src/cli/edit_config.zig +++ b/src/cli/edit_config.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const args = @import("args.zig"); const Allocator = std.mem.Allocator; const Action = @import("ghostty.zig").Action; diff --git a/src/cli/ssh-cache/DiskCache.zig b/src/cli/ssh-cache/DiskCache.zig index fe043569f..62620ecb0 100644 --- a/src/cli/ssh-cache/DiskCache.zig +++ b/src/cli/ssh-cache/DiskCache.zig @@ -5,7 +5,7 @@ const DiskCache = @This(); const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const internal_os = @import("../../os/main.zig"); const xdg = internal_os.xdg; diff --git a/src/config/CApi.zig b/src/config/CApi.zig index bdc59797a..d3f714a45 100644 --- a/src/config/CApi.zig +++ b/src/config/CApi.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const cli = @import("../cli.zig"); const inputpkg = @import("../input.zig"); const state = &@import("../global.zig").state; diff --git a/src/config/ClipboardCodepointMap.zig b/src/config/ClipboardCodepointMap.zig index 354db10d9..fbe539127 100644 --- a/src/config/ClipboardCodepointMap.zig +++ b/src/config/ClipboardCodepointMap.zig @@ -4,7 +4,7 @@ const ClipboardCodepointMap = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; // To ease our usage later, we map it directly to formatter entries. diff --git a/src/config/Config.zig b/src/config/Config.zig index 6469c333e..daa4d7387 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -13,7 +13,7 @@ const Config = @This(); const std = @import("std"); const builtin = @import("builtin"); const build_config = @import("../build_config.zig"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const global_state = &@import("../global.zig").state; diff --git a/src/config/conditional.zig b/src/config/conditional.zig index 5d5d204c5..aabfeca1c 100644 --- a/src/config/conditional.zig +++ b/src/config/conditional.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; /// Conditionals in Ghostty configuration are based on a static, typed diff --git a/src/config/edit.zig b/src/config/edit.zig index 6087106e7..6c18abadc 100644 --- a/src/config/edit.zig +++ b/src/config/edit.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const internal_os = @import("../os/main.zig"); diff --git a/src/config/file_load.zig b/src/config/file_load.zig index 8dbefeea8..7885de32a 100644 --- a/src/config/file_load.zig +++ b/src/config/file_load.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const internal_os = @import("../os/main.zig"); diff --git a/src/config/io.zig b/src/config/io.zig index 9d9a127e8..a1e433b6a 100644 --- a/src/config/io.zig +++ b/src/config/io.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const string = @import("string.zig"); diff --git a/src/config/path.zig b/src/config/path.zig index aeba69b94..ebcd084d2 100644 --- a/src/config/path.zig +++ b/src/config/path.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; diff --git a/src/config/theme.zig b/src/config/theme.zig index b1188a5c4..983ce647d 100644 --- a/src/config/theme.zig +++ b/src/config/theme.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const global_state = &@import("../global.zig").state; const internal_os = @import("../os/main.zig"); diff --git a/src/datastruct/blocking_queue.zig b/src/datastruct/blocking_queue.zig index c95b6b96a..339007c3a 100644 --- a/src/datastruct/blocking_queue.zig +++ b/src/datastruct/blocking_queue.zig @@ -3,7 +3,7 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; /// Returns a blocking queue implementation for type T. diff --git a/src/datastruct/cache_table.zig b/src/datastruct/cache_table.zig index fbfb30d71..491723989 100644 --- a/src/datastruct/cache_table.zig +++ b/src/datastruct/cache_table.zig @@ -1,7 +1,7 @@ const fastmem = @import("../fastmem.zig"); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; /// An associative data structure used for efficiently storing and /// retrieving values which are able to be recomputed if necessary. diff --git a/src/datastruct/circ_buf.zig b/src/datastruct/circ_buf.zig index 646a00940..baef6f9cf 100644 --- a/src/datastruct/circ_buf.zig +++ b/src/datastruct/circ_buf.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const fastmem = @import("../fastmem.zig"); diff --git a/src/datastruct/lru.zig b/src/datastruct/lru.zig index 1c6df69ce..83d2cf8ef 100644 --- a/src/datastruct/lru.zig +++ b/src/datastruct/lru.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; /// Create a HashMap for a key type that can be automatically hashed. diff --git a/src/datastruct/segmented_pool.zig b/src/datastruct/segmented_pool.zig index 8a91ed745..328eb2398 100644 --- a/src/datastruct/segmented_pool.zig +++ b/src/datastruct/segmented_pool.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const testing = std.testing; diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index eb371187c..be24187f6 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const build_config = @import("../build_config.zig"); const ArenaAllocator = std.heap.ArenaAllocator; const Allocator = std.mem.Allocator; diff --git a/src/fastmem.zig b/src/fastmem.zig index d4a0a7750..a21f84c58 100644 --- a/src/fastmem.zig +++ b/src/fastmem.zig @@ -1,6 +1,5 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; /// Same as @memmove but prefers libc memmove if it is /// available because it is generally much faster?. diff --git a/src/font/Atlas.zig b/src/font/Atlas.zig index e2d9a5de2..0648c0edf 100644 --- a/src/font/Atlas.zig +++ b/src/font/Atlas.zig @@ -16,7 +16,7 @@ const Atlas = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const testing = std.testing; const fastmem = @import("../fastmem.zig"); diff --git a/src/font/CodepointMap.zig b/src/font/CodepointMap.zig index 5b174f129..564bf013f 100644 --- a/src/font/CodepointMap.zig +++ b/src/font/CodepointMap.zig @@ -4,7 +4,7 @@ const CodepointMap = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const discovery = @import("discovery.zig"); diff --git a/src/font/Collection.zig b/src/font/Collection.zig index b587245aa..6726fb64a 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -16,7 +16,7 @@ const Collection = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const config = @import("../config.zig"); const comparison = @import("../datastruct/comparison.zig"); diff --git a/src/font/DeferredFace.zig b/src/font/DeferredFace.zig index 290a01d74..61d0adf8b 100644 --- a/src/font/DeferredFace.zig +++ b/src/font/DeferredFace.zig @@ -7,7 +7,7 @@ const DeferredFace = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const fontconfig = @import("fontconfig"); const macos = @import("macos"); diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index 3fd9cf204..52aedefc6 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -19,7 +19,7 @@ const SharedGrid = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const renderer = @import("../renderer.zig"); const font = @import("main.zig"); diff --git a/src/font/SharedGridSet.zig b/src/font/SharedGridSet.zig index 4512e23cc..b832139b3 100644 --- a/src/font/SharedGridSet.zig +++ b/src/font/SharedGridSet.zig @@ -11,7 +11,7 @@ const SharedGridSet = @This(); const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const font = @import("main.zig"); diff --git a/src/font/discovery.zig b/src/font/discovery.zig index 390465916..2f8412790 100644 --- a/src/font/discovery.zig +++ b/src/font/discovery.zig @@ -1,7 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const fontconfig = @import("fontconfig"); const macos = @import("macos"); const opentype = @import("opentype.zig"); diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 9e7bc4d5d..71bacb545 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const macos = @import("macos"); const harfbuzz = @import("harfbuzz"); diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 95f05881b..ced313a94 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -9,7 +9,7 @@ const builtin = @import("builtin"); const freetype = @import("freetype"); const harfbuzz = @import("harfbuzz"); const stb = @import("../../stb/main.zig"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; const font = @import("../main.zig"); diff --git a/src/font/face/web_canvas.zig b/src/font/face/web_canvas.zig index 7ea2f0426..d6a3ca449 100644 --- a/src/font/face/web_canvas.zig +++ b/src/font/face/web_canvas.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; const js = @import("zig-js"); diff --git a/src/font/opentype/head.zig b/src/font/opentype/head.zig index b4ee3ffd4..69b951821 100644 --- a/src/font/opentype/head.zig +++ b/src/font/opentype/head.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const sfnt = @import("sfnt.zig"); /// Font Header Table diff --git a/src/font/opentype/hhea.zig b/src/font/opentype/hhea.zig index 300f29c7a..2a86e5b82 100644 --- a/src/font/opentype/hhea.zig +++ b/src/font/opentype/hhea.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const sfnt = @import("sfnt.zig"); /// Horizontal Header Table diff --git a/src/font/opentype/os2.zig b/src/font/opentype/os2.zig index a18538d5f..9bcec973d 100644 --- a/src/font/opentype/os2.zig +++ b/src/font/opentype/os2.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const sfnt = @import("sfnt.zig"); pub const FSSelection = packed struct(sfnt.uint16) { diff --git a/src/font/opentype/post.zig b/src/font/opentype/post.zig index ff56a5013..b739bd224 100644 --- a/src/font/opentype/post.zig +++ b/src/font/opentype/post.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const sfnt = @import("sfnt.zig"); /// PostScript Table diff --git a/src/font/opentype/sfnt.zig b/src/font/opentype/sfnt.zig index d97d9e2d5..9373cda03 100644 --- a/src/font/opentype/sfnt.zig +++ b/src/font/opentype/sfnt.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; /// 8-bit unsigned integer. diff --git a/src/font/opentype/svg.zig b/src/font/opentype/svg.zig index ff8eeed49..348a1dc5b 100644 --- a/src/font/opentype/svg.zig +++ b/src/font/opentype/svg.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const font = @import("../main.zig"); /// SVG glyphs description table. diff --git a/src/font/shaper/Cache.zig b/src/font/shaper/Cache.zig index bcc0a1d93..70b49bb75 100644 --- a/src/font/shaper/Cache.zig +++ b/src/font/shaper/Cache.zig @@ -11,7 +11,7 @@ pub const Cache = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const font = @import("../main.zig"); const CacheTable = @import("../../datastruct/main.zig").CacheTable; diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index c2cfb389c..953956eb9 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const macos = @import("macos"); const trace = @import("tracy").trace; diff --git a/src/font/shaper/feature.zig b/src/font/shaper/feature.zig index 40770376b..b85d2867d 100644 --- a/src/font/shaper/feature.zig +++ b/src/font/shaper/feature.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const log = std.log.scoped(.font_shaper); diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 83de69cfe..f255d8f11 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const harfbuzz = @import("harfbuzz"); const font = @import("../main.zig"); diff --git a/src/font/shaper/noop.zig b/src/font/shaper/noop.zig index 8723071d7..5d2b1f54f 100644 --- a/src/font/shaper/noop.zig +++ b/src/font/shaper/noop.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const trace = @import("tracy").trace; const font = @import("../main.zig"); diff --git a/src/font/shaper/run.zig b/src/font/shaper/run.zig index da3c51cee..79e4bfc18 100644 --- a/src/font/shaper/run.zig +++ b/src/font/shaper/run.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const font = @import("../main.zig"); const shape = @import("../shape.zig"); diff --git a/src/font/shaper/web_canvas.zig b/src/font/shaper/web_canvas.zig index e0f0e1a00..c8334ec9d 100644 --- a/src/font/shaper/web_canvas.zig +++ b/src/font/shaper/web_canvas.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const font = @import("../main.zig"); const terminal = @import("../../terminal/main.zig"); diff --git a/src/font/sprite/Face.zig b/src/font/sprite/Face.zig index 5442890bf..29a7da69c 100644 --- a/src/font/sprite/Face.zig +++ b/src/font/sprite/Face.zig @@ -14,7 +14,7 @@ const Face = @This(); const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const wuffs = @import("wuffs"); const z2d = @import("z2d"); diff --git a/src/font/sprite/canvas.zig b/src/font/sprite/canvas.zig index a77b90a56..19d27eb45 100644 --- a/src/font/sprite/canvas.zig +++ b/src/font/sprite/canvas.zig @@ -1,7 +1,7 @@ //! This exposes primitives to draw 2D graphics and export the graphic to //! a font atlas. const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const z2d = @import("z2d"); const font = @import("../main.zig"); diff --git a/src/font/sprite/draw/block.zig b/src/font/sprite/draw/block.zig index 571f25a79..96910ce57 100644 --- a/src/font/sprite/draw/block.zig +++ b/src/font/sprite/draw/block.zig @@ -6,7 +6,7 @@ //! const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const z2d = @import("z2d"); diff --git a/src/font/sprite/draw/box.zig b/src/font/sprite/draw/box.zig index f14e5a3f9..ff6fa292e 100644 --- a/src/font/sprite/draw/box.zig +++ b/src/font/sprite/draw/box.zig @@ -12,7 +12,7 @@ //! const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const z2d = @import("z2d"); diff --git a/src/font/sprite/draw/braille.zig b/src/font/sprite/draw/braille.zig index c756ff369..fb2d54748 100644 --- a/src/font/sprite/draw/braille.zig +++ b/src/font/sprite/draw/braille.zig @@ -23,7 +23,7 @@ //! const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const font = @import("../../main.zig"); diff --git a/src/font/sprite/draw/branch.zig b/src/font/sprite/draw/branch.zig index ac7220390..3cca6b7ff 100644 --- a/src/font/sprite/draw/branch.zig +++ b/src/font/sprite/draw/branch.zig @@ -16,7 +16,7 @@ //! const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const common = @import("common.zig"); diff --git a/src/font/sprite/draw/common.zig b/src/font/sprite/draw/common.zig index 67b9dc778..18efe6c65 100644 --- a/src/font/sprite/draw/common.zig +++ b/src/font/sprite/draw/common.zig @@ -4,7 +4,7 @@ //! rather than being single-use. const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const z2d = @import("z2d"); diff --git a/src/font/sprite/draw/special.zig b/src/font/sprite/draw/special.zig index e41cac487..c1d795b9f 100644 --- a/src/font/sprite/draw/special.zig +++ b/src/font/sprite/draw/special.zig @@ -7,7 +7,7 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const font = @import("../../main.zig"); const Sprite = font.sprite.Sprite; diff --git a/src/font/sprite/draw/symbols_for_legacy_computing.zig b/src/font/sprite/draw/symbols_for_legacy_computing.zig index 164aa1ac3..7abc179fe 100644 --- a/src/font/sprite/draw/symbols_for_legacy_computing.zig +++ b/src/font/sprite/draw/symbols_for_legacy_computing.zig @@ -21,7 +21,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const z2d = @import("z2d"); diff --git a/src/font/sprite/draw/symbols_for_legacy_computing_supplement.zig b/src/font/sprite/draw/symbols_for_legacy_computing_supplement.zig index f43949eb9..45148ee76 100644 --- a/src/font/sprite/draw/symbols_for_legacy_computing_supplement.zig +++ b/src/font/sprite/draw/symbols_for_legacy_computing_supplement.zig @@ -49,7 +49,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const z2d = @import("z2d"); diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 94868c2c1..c9f3a7343 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -4,7 +4,7 @@ const Binding = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const build_config = @import("../build_config.zig"); const uucode = @import("uucode"); const EntryFormatter = @import("../config/formatter.zig").EntryFormatter; diff --git a/src/input/command.zig b/src/input/command.zig index f38295a4f..b6f75080d 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const Action = @import("Binding.zig").Action; diff --git a/src/input/paste.zig b/src/input/paste.zig index 29787c385..197386e89 100644 --- a/src/input/paste.zig +++ b/src/input/paste.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Terminal = @import("../terminal/Terminal.zig"); pub const Options = struct { diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 3f9888841..86a7b473c 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -4,7 +4,7 @@ const Inspector = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const cimgui = @import("cimgui"); diff --git a/src/inspector/cell.zig b/src/inspector/cell.zig index b2dc59fef..2f72556bd 100644 --- a/src/inspector/cell.zig +++ b/src/inspector/cell.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const cimgui = @import("cimgui"); const terminal = @import("../terminal/main.zig"); diff --git a/src/inspector/page.zig b/src/inspector/page.zig index 0b8609d5a..2cc62772e 100644 --- a/src/inspector/page.zig +++ b/src/inspector/page.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const cimgui = @import("cimgui"); const terminal = @import("../terminal/main.zig"); diff --git a/src/lib/union.zig b/src/lib/union.zig index 9fe5e999c..c1513fc79 100644 --- a/src/lib/union.zig +++ b/src/lib/union.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; const Target = @import("target.zig").Target; diff --git a/src/main_c.zig b/src/main_c.zig index d3fb753ef..9d48f376d 100644 --- a/src/main_c.zig +++ b/src/main_c.zig @@ -8,7 +8,7 @@ // it could be expanded to be general purpose in the future. const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("quirks.zig").inlineAssert; const posix = std.posix; const builtin = @import("builtin"); const build_config = @import("build_config.zig"); diff --git a/src/os/args.zig b/src/os/args.zig index a531a418b..871663504 100644 --- a/src/os/args.zig +++ b/src/os/args.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const objc = @import("objc"); const macos = @import("macos"); diff --git a/src/os/cgroup.zig b/src/os/cgroup.zig index 4b5ccc4d3..a55732ca3 100644 --- a/src/os/cgroup.zig +++ b/src/os/cgroup.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const linux = std.os.linux; const posix = std.posix; const Allocator = std.mem.Allocator; diff --git a/src/os/flatpak.zig b/src/os/flatpak.zig index 7bd84bc27..1b517cd83 100644 --- a/src/os/flatpak.zig +++ b/src/os/flatpak.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const posix = std.posix; diff --git a/src/os/homedir.zig b/src/os/homedir.zig index f3d6e4498..28b4a0f73 100644 --- a/src/os/homedir.zig +++ b/src/os/homedir.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const passwd = @import("passwd.zig"); const posix = std.posix; const objc = @import("objc"); diff --git a/src/os/locale.zig b/src/os/locale.zig index 92a63741f..742e1629b 100644 --- a/src/os/locale.zig +++ b/src/os/locale.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const macos = @import("macos"); const objc = @import("objc"); const internal_os = @import("main.zig"); diff --git a/src/os/macos.zig b/src/os/macos.zig index 100d0fe44..fcd1c3e5a 100644 --- a/src/os/macos.zig +++ b/src/os/macos.zig @@ -1,7 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); const build_config = @import("../build_config.zig"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const objc = @import("objc"); const Allocator = std.mem.Allocator; diff --git a/src/os/mouse.zig b/src/os/mouse.zig index fa39882c7..b592bd94a 100644 --- a/src/os/mouse.zig +++ b/src/os/mouse.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const objc = @import("objc"); const log = std.log.scoped(.os); diff --git a/src/os/xdg.zig b/src/os/xdg.zig index e120ed857..57ef075aa 100644 --- a/src/os/xdg.zig +++ b/src/os/xdg.zig @@ -3,7 +3,7 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const posix = std.posix; const homedir = @import("homedir.zig"); diff --git a/src/quirks.zig b/src/quirks.zig index e3288afb6..5129923d2 100644 --- a/src/quirks.zig +++ b/src/quirks.zig @@ -27,3 +27,20 @@ pub fn disableDefaultFontFeatures(face: *const font.Face) bool { // error.OutOfMemory => return false, // }; } + +/// We use our own assert function instead of `std.debug.assert`. +/// +/// The only difference between this and the one in +/// the stdlib is that this version is marked inline. +/// +/// The reason for this is that, despite the promises of the doc comment +/// on the stdlib function, the function call to `std.debug.assert` isn't +/// always optimized away in `ReleaseFast` mode, at least in Zig 0.15.2. +/// +/// In the majority of places, the overhead from calling an empty function +/// is negligible, but we have some asserts inside tight loops and hotpaths +/// that cause significant overhead (as much as 15-20%) when they don't get +/// optimized out. +pub inline fn inlineAssert(ok: bool) void { + if (!ok) unreachable; +} diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index f4201edcc..168f54c2b 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -2,7 +2,7 @@ pub const Metal = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const objc = @import("objc"); diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 673f79501..efd98601c 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -2,7 +2,7 @@ pub const OpenGL = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const gl = @import("opengl"); diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index fd9d0f51a..004cfd5fa 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -4,7 +4,7 @@ pub const Thread = @This(); const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const xev = @import("../global.zig").xev; const crash = @import("../crash/main.zig"); const internal_os = @import("../os/main.zig"); diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index 1e371b07e..855abdf76 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -1,6 +1,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const font = @import("../font/main.zig"); const terminal = @import("../terminal/main.zig"); const renderer = @import("../renderer.zig"); diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 912dcc457..ac4cd95a2 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -20,7 +20,7 @@ const Image = imagepkg.Image; const ImageMap = imagepkg.ImageMap; const ImagePlacementList = std.ArrayListUnmanaged(imagepkg.Placement); const shadertoy = @import("shadertoy.zig"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const Terminal = terminal.Terminal; diff --git a/src/renderer/image.zig b/src/renderer/image.zig index d89c46730..7089f5a8b 100644 --- a/src/renderer/image.zig +++ b/src/renderer/image.zig @@ -1,6 +1,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const wuffs = @import("wuffs"); const Renderer = @import("../renderer.zig").Renderer; diff --git a/src/renderer/message.zig b/src/renderer/message.zig index e33922ae2..b36a99d5c 100644 --- a/src/renderer/message.zig +++ b/src/renderer/message.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const configpkg = @import("../config.zig"); const font = @import("../font/main.zig"); diff --git a/src/renderer/metal/Frame.zig b/src/renderer/metal/Frame.zig index c766fb8ed..e919a01ed 100644 --- a/src/renderer/metal/Frame.zig +++ b/src/renderer/metal/Frame.zig @@ -3,7 +3,7 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const builtin = @import("builtin"); const objc = @import("objc"); diff --git a/src/renderer/metal/IOSurfaceLayer.zig b/src/renderer/metal/IOSurfaceLayer.zig index 5a6bf7307..afee0953f 100644 --- a/src/renderer/metal/IOSurfaceLayer.zig +++ b/src/renderer/metal/IOSurfaceLayer.zig @@ -4,7 +4,7 @@ const IOSurfaceLayer = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const builtin = @import("builtin"); const objc = @import("objc"); const macos = @import("macos"); diff --git a/src/renderer/metal/Pipeline.zig b/src/renderer/metal/Pipeline.zig index 0b8e99159..cf495edda 100644 --- a/src/renderer/metal/Pipeline.zig +++ b/src/renderer/metal/Pipeline.zig @@ -3,7 +3,7 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const builtin = @import("builtin"); const macos = @import("macos"); const objc = @import("objc"); diff --git a/src/renderer/metal/RenderPass.zig b/src/renderer/metal/RenderPass.zig index d42d9fa21..eb458e054 100644 --- a/src/renderer/metal/RenderPass.zig +++ b/src/renderer/metal/RenderPass.zig @@ -3,7 +3,7 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const builtin = @import("builtin"); const objc = @import("objc"); diff --git a/src/renderer/metal/Sampler.zig b/src/renderer/metal/Sampler.zig index 0f4de8848..d1069948e 100644 --- a/src/renderer/metal/Sampler.zig +++ b/src/renderer/metal/Sampler.zig @@ -3,7 +3,7 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const builtin = @import("builtin"); const objc = @import("objc"); diff --git a/src/renderer/metal/Target.zig b/src/renderer/metal/Target.zig index 15780189b..fe572a63b 100644 --- a/src/renderer/metal/Target.zig +++ b/src/renderer/metal/Target.zig @@ -5,7 +5,7 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const builtin = @import("builtin"); const objc = @import("objc"); const macos = @import("macos"); diff --git a/src/renderer/metal/Texture.zig b/src/renderer/metal/Texture.zig index cde50e8de..c339277e8 100644 --- a/src/renderer/metal/Texture.zig +++ b/src/renderer/metal/Texture.zig @@ -3,7 +3,7 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const builtin = @import("builtin"); const objc = @import("objc"); diff --git a/src/renderer/metal/buffer.zig b/src/renderer/metal/buffer.zig index 43320a60b..8d2254640 100644 --- a/src/renderer/metal/buffer.zig +++ b/src/renderer/metal/buffer.zig @@ -1,6 +1,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const objc = @import("objc"); const macos = @import("macos"); diff --git a/src/renderer/metal/shaders.zig b/src/renderer/metal/shaders.zig index bf3bcc6e4..653c0dea2 100644 --- a/src/renderer/metal/shaders.zig +++ b/src/renderer/metal/shaders.zig @@ -1,6 +1,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const macos = @import("macos"); const objc = @import("objc"); const math = @import("../../math.zig"); diff --git a/src/renderer/opengl/Frame.zig b/src/renderer/opengl/Frame.zig index 4c23fe106..3d0efbdfb 100644 --- a/src/renderer/opengl/Frame.zig +++ b/src/renderer/opengl/Frame.zig @@ -3,7 +3,7 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const builtin = @import("builtin"); const gl = @import("opengl"); diff --git a/src/renderer/opengl/Pipeline.zig b/src/renderer/opengl/Pipeline.zig index c3d414ff2..04130752a 100644 --- a/src/renderer/opengl/Pipeline.zig +++ b/src/renderer/opengl/Pipeline.zig @@ -3,7 +3,7 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const builtin = @import("builtin"); const gl = @import("opengl"); diff --git a/src/renderer/opengl/RenderPass.zig b/src/renderer/opengl/RenderPass.zig index 7a9365d88..1ef151c45 100644 --- a/src/renderer/opengl/RenderPass.zig +++ b/src/renderer/opengl/RenderPass.zig @@ -3,7 +3,7 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const builtin = @import("builtin"); const gl = @import("opengl"); diff --git a/src/renderer/opengl/Sampler.zig b/src/renderer/opengl/Sampler.zig index 98d4b35fe..66f579221 100644 --- a/src/renderer/opengl/Sampler.zig +++ b/src/renderer/opengl/Sampler.zig @@ -3,7 +3,7 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const builtin = @import("builtin"); const gl = @import("opengl"); diff --git a/src/renderer/opengl/Target.zig b/src/renderer/opengl/Target.zig index 1b3a13ed0..e9de7216e 100644 --- a/src/renderer/opengl/Target.zig +++ b/src/renderer/opengl/Target.zig @@ -5,7 +5,7 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const builtin = @import("builtin"); const gl = @import("opengl"); diff --git a/src/renderer/opengl/Texture.zig b/src/renderer/opengl/Texture.zig index 2f3e7f46a..71018d941 100644 --- a/src/renderer/opengl/Texture.zig +++ b/src/renderer/opengl/Texture.zig @@ -3,7 +3,7 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const builtin = @import("builtin"); const gl = @import("opengl"); diff --git a/src/renderer/opengl/buffer.zig b/src/renderer/opengl/buffer.zig index 48b6f410e..17d34e500 100644 --- a/src/renderer/opengl/buffer.zig +++ b/src/renderer/opengl/buffer.zig @@ -1,6 +1,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const gl = @import("opengl"); const OpenGL = @import("../OpenGL.zig"); diff --git a/src/renderer/opengl/shaders.zig b/src/renderer/opengl/shaders.zig index 80980bac7..68c1f36a3 100644 --- a/src/renderer/opengl/shaders.zig +++ b/src/renderer/opengl/shaders.zig @@ -1,6 +1,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const math = @import("../../math.zig"); const Pipeline = @import("Pipeline.zig"); diff --git a/src/renderer/shadertoy.zig b/src/renderer/shadertoy.zig index b0a190a8b..38860932b 100644 --- a/src/renderer/shadertoy.zig +++ b/src/renderer/shadertoy.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const glslang = @import("glslang"); diff --git a/src/simd/base64.zig b/src/simd/base64.zig index 88b97bb03..81feeb723 100644 --- a/src/simd/base64.zig +++ b/src/simd/base64.zig @@ -1,6 +1,6 @@ const std = @import("std"); const options = @import("build_options"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const scalar_decoder = @import("base64_scalar.zig").scalar_decoder; const log = std.log.scoped(.simd_base64); diff --git a/src/simd/base64_scalar.zig b/src/simd/base64_scalar.zig index 4172ed107..08886f187 100644 --- a/src/simd/base64_scalar.zig +++ b/src/simd/base64_scalar.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; pub const scalar_decoder: Base64Decoder = .init( std.base64.standard_alphabet_chars, diff --git a/src/simd/vt.zig b/src/simd/vt.zig index 8e974ad7e..fa8754fa2 100644 --- a/src/simd/vt.zig +++ b/src/simd/vt.zig @@ -1,6 +1,6 @@ const std = @import("std"); const options = @import("build_options"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const indexOf = @import("index_of.zig").indexOf; // vt.cpp diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index a589af179..98cc1a9f3 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -6,7 +6,7 @@ const PageList = @This(); const std = @import("std"); const build_options = @import("terminal_options"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const fastmem = @import("../fastmem.zig"); const DoublyLinkedList = @import("../datastruct/main.zig").IntrusiveDoublyLinkedList; const color = @import("color.zig"); diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 4a02e2b13..612c93ee0 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -6,7 +6,7 @@ const Parser = @This(); const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; const table = @import("parse_table.zig").table; const osc = @import("osc.zig"); diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 09e957786..24f4497fe 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -3,7 +3,7 @@ const Screen = @This(); const std = @import("std"); const build_options = @import("terminal_options"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const ansi = @import("ansi.zig"); const charsets = @import("charsets.zig"); const fastmem = @import("../fastmem.zig"); diff --git a/src/terminal/ScreenSet.zig b/src/terminal/ScreenSet.zig index 1b6b053fe..418888694 100644 --- a/src/terminal/ScreenSet.zig +++ b/src/terminal/ScreenSet.zig @@ -8,7 +8,7 @@ const ScreenSet = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; const Screen = @import("Screen.zig"); diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index 59cb4ef50..e10f83c9e 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -2,7 +2,7 @@ const Selection = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const page = @import("page.zig"); const point = @import("point.zig"); const PageList = @import("PageList.zig"); diff --git a/src/terminal/Tabstops.zig b/src/terminal/Tabstops.zig index c352cb351..13d6dc52e 100644 --- a/src/terminal/Tabstops.zig +++ b/src/terminal/Tabstops.zig @@ -12,7 +12,7 @@ const Tabstops = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; const testing = std.testing; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const fastmem = @import("../fastmem.zig"); /// Unit is the type we use per tabstop unit (see file docs). diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 472b390d1..8fa0e655d 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -6,7 +6,7 @@ const Terminal = @This(); const std = @import("std"); const build_options = @import("terminal_options"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; const unicode = @import("../unicode/main.zig"); diff --git a/src/terminal/apc.zig b/src/terminal/apc.zig index 704c3fbe3..0585c78ba 100644 --- a/src/terminal/apc.zig +++ b/src/terminal/apc.zig @@ -1,6 +1,6 @@ const std = @import("std"); const build_options = @import("terminal_options"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const kitty_gfx = @import("kitty/graphics.zig"); diff --git a/src/terminal/bitmap_allocator.zig b/src/terminal/bitmap_allocator.zig index 894172b4c..258d73071 100644 --- a/src/terminal/bitmap_allocator.zig +++ b/src/terminal/bitmap_allocator.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const size = @import("size.zig"); const getOffset = size.getOffset; diff --git a/src/terminal/c/key_encode.zig b/src/terminal/c/key_encode.zig index 47bd904e0..1e0367829 100644 --- a/src/terminal/c/key_encode.zig +++ b/src/terminal/c/key_encode.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const lib_alloc = @import("../../lib/allocator.zig"); const CAllocator = lib_alloc.Allocator; diff --git a/src/terminal/c/key_event.zig b/src/terminal/c/key_event.zig index b52932fdd..6608c84b1 100644 --- a/src/terminal/c/key_event.zig +++ b/src/terminal/c/key_event.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const lib_alloc = @import("../../lib/allocator.zig"); const CAllocator = lib_alloc.Allocator; diff --git a/src/terminal/c/osc.zig b/src/terminal/c/osc.zig index 124fc3b7c..9c6286e6a 100644 --- a/src/terminal/c/osc.zig +++ b/src/terminal/c/osc.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const builtin = @import("builtin"); const lib_alloc = @import("../../lib/allocator.zig"); const CAllocator = lib_alloc.Allocator; diff --git a/src/terminal/c/sgr.zig b/src/terminal/c/sgr.zig index e65b9e3ee..ec35ce608 100644 --- a/src/terminal/c/sgr.zig +++ b/src/terminal/c/sgr.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); diff --git a/src/terminal/charsets.zig b/src/terminal/charsets.zig index b4fd58efc..05ebb40b6 100644 --- a/src/terminal/charsets.zig +++ b/src/terminal/charsets.zig @@ -1,6 +1,6 @@ const std = @import("std"); const build_options = @import("terminal_options"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const LibEnum = @import("../lib/enum.zig").Enum; /// The available charset slots for a terminal. diff --git a/src/terminal/color.zig b/src/terminal/color.zig index 4492d65ae..ce7e9ce5d 100644 --- a/src/terminal/color.zig +++ b/src/terminal/color.zig @@ -1,7 +1,7 @@ const colorpkg = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const x11_color = @import("x11_color.zig"); /// The default palette. diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index 971ea13a0..52f696131 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -1,6 +1,6 @@ const std = @import("std"); const build_options = @import("terminal_options"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const terminal = @import("main.zig"); const DCS = terminal.DCS; diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index 35fd71665..1f4f2468b 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const color = @import("color.zig"); const size = @import("size.zig"); diff --git a/src/terminal/hash_map.zig b/src/terminal/hash_map.zig index 23b10950e..a9d081782 100644 --- a/src/terminal/hash_map.zig +++ b/src/terminal/hash_map.zig @@ -32,7 +32,7 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const autoHash = std.hash.autoHash; const math = std.math; const mem = std.mem; diff --git a/src/terminal/hyperlink.zig b/src/terminal/hyperlink.zig index f0c2738b1..b60ed795b 100644 --- a/src/terminal/hyperlink.zig +++ b/src/terminal/hyperlink.zig @@ -1,6 +1,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const hash_map = @import("hash_map.zig"); const AutoOffsetHashMap = hash_map.AutoOffsetHashMap; const pagepkg = @import("page.zig"); diff --git a/src/terminal/kitty/graphics_command.zig b/src/terminal/kitty/graphics_command.zig index 99a7cdaac..dfce56e35 100644 --- a/src/terminal/kitty/graphics_command.zig +++ b/src/terminal/kitty/graphics_command.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const simd = @import("../../simd/main.zig"); diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index 1559c0cec..b5f8ad61b 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const renderer = @import("../../renderer.zig"); diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index f485e0161..d5e0735a6 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const posix = std.posix; diff --git a/src/terminal/kitty/graphics_render.zig b/src/terminal/kitty/graphics_render.zig index af888582f..4db9d1ab1 100644 --- a/src/terminal/kitty/graphics_render.zig +++ b/src/terminal/kitty/graphics_render.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const terminal = @import("../main.zig"); diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index cfa654ae8..8ff68e3fa 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; diff --git a/src/terminal/kitty/graphics_unicode.zig b/src/terminal/kitty/graphics_unicode.zig index b2a90296c..ceadf63ee 100644 --- a/src/terminal/kitty/graphics_unicode.zig +++ b/src/terminal/kitty/graphics_unicode.zig @@ -2,7 +2,7 @@ //! Kitty graphics protocol unicode placeholder, virtual placement feature. const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const terminal = @import("../main.zig"); const kitty_gfx = terminal.kitty.graphics; diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index effdfbd62..ca212bae0 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -9,7 +9,7 @@ const std = @import("std"); const builtin = @import("builtin"); const build_options = @import("terminal_options"); const mem = std.mem; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = mem.Allocator; const LibEnum = @import("../lib/enum.zig").Enum; const RGB = @import("color.zig").RGB; diff --git a/src/terminal/page.zig b/src/terminal/page.zig index b13c625ed..4b80aae45 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -3,7 +3,7 @@ const builtin = @import("builtin"); const build_options = @import("terminal_options"); const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; const posix = std.posix; const fastmem = @import("../fastmem.zig"); diff --git a/src/terminal/point.zig b/src/terminal/point.zig index e7e2a8840..fb44aae88 100644 --- a/src/terminal/point.zig +++ b/src/terminal/point.zig @@ -1,6 +1,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const size = @import("size.zig"); /// The possible reference locations for a point. When someone says "(42, 80)" diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig index e07de4e97..651aaa3a0 100644 --- a/src/terminal/ref_counted_set.zig +++ b/src/terminal/ref_counted_set.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const size = @import("size.zig"); const Offset = size.Offset; diff --git a/src/terminal/search/pagelist.zig b/src/terminal/search/pagelist.zig index 8b6b57949..8a01a61fb 100644 --- a/src/terminal/search/pagelist.zig +++ b/src/terminal/search/pagelist.zig @@ -1,6 +1,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const CircBuf = @import("../../datastruct/main.zig").CircBuf; const terminal = @import("../main.zig"); diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 07d700742..d2d138442 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; const point = @import("../point.zig"); diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index db60a6670..2d09c781a 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const CircBuf = @import("../../datastruct/main.zig").CircBuf; const terminal = @import("../main.zig"); diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index 5b9199afc..70fc3088f 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; const point = @import("../point.zig"); diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index b9765ca6a..7712563cf 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -2,7 +2,7 @@ const std = @import("std"); const build_options = @import("terminal_options"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; const lib = @import("../lib/main.zig"); const color = @import("color.zig"); diff --git a/src/terminal/size.zig b/src/terminal/size.zig index 9c99f7732..13ba636c3 100644 --- a/src/terminal/size.zig +++ b/src/terminal/size.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; /// The maximum size of a page in bytes. We use a u16 here because any /// smaller bit size by Zig is upgraded anyways to a u16 on mainstream diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index de83dbe9c..9db1dc60b 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1,7 +1,7 @@ const streampkg = @This(); const std = @import("std"); const build_options = @import("terminal_options"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; const simd = @import("../simd/main.zig"); diff --git a/src/terminal/style.zig b/src/terminal/style.zig index d7e6b03ab..f40d5350f 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const configpkg = @import("../config.zig"); const color = @import("color.zig"); const sgr = @import("sgr.zig"); diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index 54cd7cdd5..56d4c5fe2 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -4,7 +4,7 @@ //! documentation. const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const oni = @import("oniguruma"); const log = std.log.scoped(.terminal_tmux); diff --git a/src/terminal/x11_color.zig b/src/terminal/x11_color.zig index 977cd4538..477218d6f 100644 --- a/src/terminal/x11_color.zig +++ b/src/terminal/x11_color.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const RGB = @import("color.zig").RGB; /// The map of all available X11 colors. diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 5dfda9a14..7c7b711fd 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -5,7 +5,7 @@ const Exec = @This(); const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const posix = std.posix; diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 9bcbd38ca..e54c7ca61 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -7,7 +7,7 @@ pub const Termio = @This(); const std = @import("std"); const builtin = @import("builtin"); const build_config = @import("../build_config.zig"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const EnvMap = std.process.EnvMap; diff --git a/src/termio/backend.zig b/src/termio/backend.zig index 280fcbde1..ebd170079 100644 --- a/src/termio/backend.zig +++ b/src/termio/backend.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const posix = std.posix; const xev = @import("../global.zig").xev; diff --git a/src/termio/mailbox.zig b/src/termio/mailbox.zig index b144b512a..e91033180 100644 --- a/src/termio/mailbox.zig +++ b/src/termio/mailbox.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const xev = @import("../global.zig").xev; const renderer = @import("../renderer.zig"); diff --git a/src/termio/message.zig b/src/termio/message.zig index ee6dbcc0f..de7ea16cb 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const apprt = @import("../apprt.zig"); const renderer = @import("../renderer.zig"); diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index fd94f77bc..431aa8bdd 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const xev = @import("../global.zig").xev; const apprt = @import("../apprt.zig"); From 9ab9bc8e197e726e2a72efec01b44934085638a1 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 17 Nov 2025 13:17:31 -0700 Subject: [PATCH 017/209] perf: small sgr parser improvements --- src/terminal/Screen.zig | 4 - src/terminal/sgr.zig | 228 +++++++++++++++++++++++++--------------- 2 files changed, 143 insertions(+), 89 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 24f4497fe..789ba90b0 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1772,10 +1772,6 @@ pub fn setAttribute(self: *Screen, attr: sgr.Attribute) !void { self.cursor.style.flags.underline = v; }, - .reset_underline => { - self.cursor.style.flags.underline = .none; - }, - .underline_color => |rgb| { self.cursor.style.underline_color = .{ .rgb = .{ .r = rgb.r, diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index 7712563cf..dc9505d14 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -32,7 +32,6 @@ pub const Attribute = union(Tag) { /// Underline the text underline: Underline, - reset_underline, underline_color: color.RGB, @"256_underline_color": u8, reset_underline_color, @@ -92,7 +91,6 @@ pub const Attribute = union(Tag) { "reset_italic", "faint", "underline", - "reset_underline", "underline_color", "256_underline_color", "reset_underline_color", @@ -186,15 +184,16 @@ pub const Parser = struct { /// Next returns the next attribute or null if there are no more attributes. pub fn next(self: *Parser) ?Attribute { if (self.idx >= self.params.len) { - // If we're at index zero it means we must have an empty - // list and an empty list implicitly means unset. - if (self.idx == 0) { - // Add one to ensure we don't loop on unset - self.idx += 1; - return .unset; - } + // We're more likely to not be done than to be done. + @branchHint(.unlikely); - return null; + // Add one to ensure we don't loop on unset + defer self.idx += 1; + + // If we're at index zero it means we must have an empty list + // and an empty list implicitly means unset, otherwise we're + // done and return null. + return if (self.idx == 0) .unset else null; } const slice = self.params[self.idx..self.params.len]; @@ -206,20 +205,30 @@ pub const Parser = struct { // If we have a colon separator then we need to ensure we're // parsing a value that allows it. - if (colon) switch (slice[0]) { - 4, 38, 48, 58 => {}, + if (colon) { + // Colons are fairly rare in the wild. + @branchHint(.unlikely); - else => { - // Consume all the colon separated values. - const start = self.idx; - while (self.params_sep.isSet(self.idx)) self.idx += 1; - self.idx += 1; - return .{ .unknown = .{ - .full = self.params, - .partial = slice[0..@min(self.idx - start + 1, slice.len)], - } }; - }, - }; + switch (slice[0]) { + 4, 38, 48, 58 => {}, + + else => { + // In real world use it's very rare + // that we receive an invalid sequence. + @branchHint(.cold); + + // Consume all the colon separated + // values and return them as unknown. + const start = self.idx; + while (self.params_sep.isSet(self.idx)) self.idx += 1; + self.idx += 1; + return .{ .unknown = .{ + .full = self.params, + .partial = slice[0..@min(self.idx - start + 1, slice.len)], + } }; + }, + } + } switch (slice[0]) { 0 => return .unset, @@ -232,25 +241,37 @@ pub const Parser = struct { 4 => underline: { if (colon) { + // Colons are fairly rare in the wild. + @branchHint(.unlikely); + assert(slice.len >= 2); if (self.isColon()) { + // Invalid/unknown SGRs are just not very likely. + @branchHint(.cold); + self.consumeUnknownColon(); break :underline; } self.idx += 1; - switch (slice[1]) { - 0 => return .reset_underline, - 1 => return .{ .underline = .single }, - 2 => return .{ .underline = .double }, - 3 => return .{ .underline = .curly }, - 4 => return .{ .underline = .dotted }, - 5 => return .{ .underline = .dashed }, + return .{ + .underline = switch (slice[1]) { + 0 => .none, + 1 => .single, + 2 => .double, + 3 => .curly, + 4 => .dotted, + 5 => .dashed, - // For unknown underline styles, just render - // a single underline. - else => return .{ .underline = .single }, - } + // For unknown underline styles, + // just render a single underline. + else => single: { + // This is quite a rare condition. + @branchHint(.cold); + break :single .single; + }, + }, + }; } return .{ .underline = .single }; @@ -272,7 +293,7 @@ pub const Parser = struct { 23 => return .reset_italic, - 24 => return .reset_underline, + 24 => return .{ .underline = .none }, 25 => return .reset_blink, @@ -286,23 +307,32 @@ pub const Parser = struct { .@"8_fg" = @enumFromInt(slice[0] - 30), }, - 38 => if (slice.len >= 2) switch (slice[1]) { - // `2` indicates direct-color (r, g, b). - // We need at least 3 more params for this to make sense. - 2 => if (self.parseDirectColor( - .direct_color_fg, - slice, - colon, - )) |v| return v, + 38 => if (slice.len >= 2) { + // We are very likely to have enough parameters. + @branchHint(.likely); - // `5` indicates indexed color. - 5 => if (slice.len >= 3) { - self.idx += 2; - return .{ - .@"256_fg" = @truncate(slice[2]), - }; - }, - else => {}, + switch (slice[1]) { + // `2` indicates direct-color (r, g, b). + // We need at least 3 more params for this to make sense. + 2 => if (self.parseDirectColor( + .direct_color_fg, + slice, + colon, + )) |v| return v, + + // `5` indicates indexed color. + 5 => if (slice.len >= 3) { + // We are very likely to have enough parameters. + @branchHint(.likely); + + self.idx += 2; + return .{ + .@"256_fg" = @truncate(slice[2]), + }; + }, + + else => {}, + } }, 39 => return .reset_fg, @@ -311,23 +341,32 @@ pub const Parser = struct { .@"8_bg" = @enumFromInt(slice[0] - 40), }, - 48 => if (slice.len >= 2) switch (slice[1]) { - // `2` indicates direct-color (r, g, b). - // We need at least 3 more params for this to make sense. - 2 => if (self.parseDirectColor( - .direct_color_bg, - slice, - colon, - )) |v| return v, + 48 => if (slice.len >= 2) { + // We are very likely to have enough parameters. + @branchHint(.likely); - // `5` indicates indexed color. - 5 => if (slice.len >= 3) { - self.idx += 2; - return .{ - .@"256_bg" = @truncate(slice[2]), - }; - }, - else => {}, + switch (slice[1]) { + // `2` indicates direct-color (r, g, b). + // We need at least 3 more params for this to make sense. + 2 => if (self.parseDirectColor( + .direct_color_bg, + slice, + colon, + )) |v| return v, + + // `5` indicates indexed color. + 5 => if (slice.len >= 3) { + // We are very likely to have enough parameters. + @branchHint(.likely); + + self.idx += 2; + return .{ + .@"256_bg" = @truncate(slice[2]), + }; + }, + + else => {}, + } }, 49 => return .reset_bg, @@ -335,23 +374,31 @@ pub const Parser = struct { 53 => return .overline, 55 => return .reset_overline, - 58 => if (slice.len >= 2) switch (slice[1]) { - // `2` indicates direct-color (r, g, b). - // We need at least 3 more params for this to make sense. - 2 => if (self.parseDirectColor( - .underline_color, - slice, - colon, - )) |v| return v, + 58 => if (slice.len >= 2) { + // We are very likely to have enough parameters. + @branchHint(.likely); - // `5` indicates indexed color. - 5 => if (slice.len >= 3) { - self.idx += 2; - return .{ - .@"256_underline_color" = @truncate(slice[2]), - }; - }, - else => {}, + switch (slice[1]) { + // `2` indicates direct-color (r, g, b). + // We need at least 3 more params for this to make sense. + 2 => if (self.parseDirectColor( + .underline_color, + slice, + colon, + )) |v| return v, + + // `5` indicates indexed color. + 5 => if (slice.len >= 3) { + // We are very likely to have enough parameters. + @branchHint(.likely); + + self.idx += 2; + return .{ + .@"256_underline_color" = @truncate(slice[2]), + }; + }, + else => {}, + } }, 59 => return .reset_underline_color, @@ -389,6 +436,9 @@ pub const Parser = struct { // If we don't have a colon, then we expect exactly 3 semicolon // separated values. if (!colon) { + // Semicolons are much more common than colons. + @branchHint(.likely); + self.idx += 4; return @unionInit(Attribute, @tagName(tag), .{ .r = @truncate(slice[2]), @@ -402,6 +452,9 @@ pub const Parser = struct { const count = self.countColon(); switch (count) { 3 => { + // This is the much more common case in the wild. + @branchHint(.likely); + self.idx += 4; return @unionInit(Attribute, @tagName(tag), .{ .r = @truncate(slice[2]), @@ -420,6 +473,9 @@ pub const Parser = struct { }, else => { + // Invalid/unknown SGRs just don't happen very often at all. + @branchHint(.cold); + self.consumeUnknownColon(); return null; }, @@ -560,7 +616,8 @@ test "sgr: underline" { { const v = testParse(&[_]u16{24}); - try testing.expect(v == .reset_underline); + try testing.expect(v == .underline); + try testing.expect(v.underline == .none); } } @@ -573,7 +630,8 @@ test "sgr: underline styles" { { const v = testParseColon(&[_]u16{ 4, 0 }); - try testing.expect(v == .reset_underline); + try testing.expect(v == .underline); + try testing.expect(v.underline == .none); } { From 5c566fa32c64c573404f2a8854d195e8c9a47c70 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Nov 2025 00:09:02 +0000 Subject: [PATCH 018/209] build(deps): bump actions/checkout from 5.0.0 to 5.0.1 Bumps [actions/checkout](https://github.com/actions/checkout) from 5.0.0 to 5.0.1. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/08c6903cd8c0fde910a37f88322edcfb5dd907a8...93cb6efe18208431cddfb8368fd83d5badbf9bfd) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 5.0.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/release-tag.yml | 8 +-- .github/workflows/release-tip.yml | 18 +++---- .github/workflows/test.yml | 62 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 5 files changed, 46 insertions(+), 46 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 56e50889b..b20c877b9 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -34,7 +34,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 9edc8b48d..3c0cb5a8c 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -56,7 +56,7 @@ jobs: fi - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: # Important so that build number generation works fetch-depth: 0 @@ -80,7 +80,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -132,7 +132,7 @@ jobs: GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }} steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - uses: DeterminateSystems/nix-installer-action@main with: @@ -306,7 +306,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Download macOS Artifacts uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index fffc0ca4c..c4710cf44 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -29,7 +29,7 @@ jobs: commit: ${{ steps.extract_build_info.outputs.commit }} commit_long: ${{ steps.extract_build_info.outputs.commit_long }} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: # Important so that build number generation works fetch-depth: 0 @@ -66,7 +66,7 @@ jobs: needs: [setup, build-macos] if: needs.setup.outputs.should_skip != 'true' steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Tip Tag run: | git config user.name "github-actions[bot]" @@ -81,7 +81,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Install sentry-cli run: | @@ -104,7 +104,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Install sentry-cli run: | @@ -127,7 +127,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Install sentry-cli run: | @@ -159,7 +159,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -217,7 +217,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: # Important so that build number generation works fetch-depth: 0 @@ -451,7 +451,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: # Important so that build number generation works fetch-depth: 0 @@ -635,7 +635,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: # Important so that build number generation works fetch-depth: 0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8a067cea3..9e384a297 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,7 +69,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -112,7 +112,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -145,7 +145,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -179,7 +179,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -222,7 +222,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -258,7 +258,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -287,7 +287,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -320,7 +320,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -366,7 +366,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -404,7 +404,7 @@ jobs: needs: [build-dist, build-snap] steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Trigger Snap workflow run: | @@ -421,7 +421,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -464,7 +464,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -509,7 +509,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 # This could be from a script if we wanted to but inlining here for now # in one place. @@ -580,7 +580,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Get required Zig version id: zig @@ -627,7 +627,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -675,7 +675,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -710,7 +710,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -737,7 +737,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -774,7 +774,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -804,7 +804,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -832,7 +832,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -859,7 +859,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -886,7 +886,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -913,7 +913,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -940,7 +940,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -974,7 +974,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -1001,7 +1001,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -1035,7 +1035,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -1104,7 +1104,7 @@ jobs: runs-on: ${{ matrix.variant.runner }} needs: test steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6 with: bundle: com.mitchellh.ghostty @@ -1123,7 +1123,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -1162,7 +1162,7 @@ jobs: # timeout-minutes: 10 # steps: # - name: Checkout Ghostty - # uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + # uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 # # - name: Start SSH # run: | diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index a0dfdf298..c76043eae 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -17,7 +17,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 From 3264ff8291c2728525d1fa7942cc704f1f58b7f8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Nov 2025 00:09:12 +0000 Subject: [PATCH 019/209] build(deps): bump cachix/install-nix-action from 31.8.3 to 31.8.4 Bumps [cachix/install-nix-action](https://github.com/cachix/install-nix-action) from 31.8.3 to 31.8.4. - [Release notes](https://github.com/cachix/install-nix-action/releases) - [Changelog](https://github.com/cachix/install-nix-action/blob/master/RELEASE.md) - [Commits](https://github.com/cachix/install-nix-action/compare/7ec16f2c061ab07b235a7245e06ed46fe9a1cab6...0b0e072294b088b73964f1d72dfdac0951439dbd) --- updated-dependencies: - dependency-name: cachix/install-nix-action dependency-version: 31.8.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/release-tag.yml | 2 +- .github/workflows/release-tip.yml | 4 +- .github/workflows/test.yml | 48 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 56e50889b..8f84dafb5 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -42,7 +42,7 @@ jobs: /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 9edc8b48d..6a8df9218 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -89,7 +89,7 @@ jobs: /nix /zig - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index fffc0ca4c..14bb32ac7 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -33,7 +33,7 @@ jobs: with: # Important so that build number generation works fetch-depth: 0 - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -166,7 +166,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8a067cea3..0048f8d2b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -79,7 +79,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -122,7 +122,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -155,7 +155,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -189,7 +189,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -232,7 +232,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -268,7 +268,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -297,7 +297,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -330,7 +330,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -376,7 +376,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -595,7 +595,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -637,7 +637,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -685,7 +685,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -720,7 +720,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -784,7 +784,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -811,7 +811,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -839,7 +839,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -866,7 +866,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -893,7 +893,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -920,7 +920,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -947,7 +947,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -981,7 +981,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1008,7 +1008,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1045,7 +1045,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1133,7 +1133,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index a0dfdf298..73080fb70 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -29,7 +29,7 @@ jobs: /zig - name: Setup Nix - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 From e0007a66e74cb4b433fdf193878c13c89616a62f Mon Sep 17 00:00:00 2001 From: Denys Zhak Date: Tue, 18 Nov 2025 01:29:59 +0100 Subject: [PATCH 020/209] feat: add sts.testing for comparison, test getDescription --- src/extra/fish.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/extra/fish.zig b/src/extra/fish.zig index 1419fde5f..12343c62f 100644 --- a/src/extra/fish.zig +++ b/src/extra/fish.zig @@ -194,13 +194,13 @@ fn getDescription(comptime help: []const u8) []const u8 { } test "getDescription" { + const testing = std.testing; + const input = "First sentence with \"quotes\"\nand newlines. Second sentence."; const expected = "First sentence with \\\"quotes\\\" and newlines."; - const result = comptime getDescription(input); comptime { - if (!std.mem.eql(u8, result, expected)) { - @compileError("getDescription test failed: expected '" ++ expected ++ "' but got '" ++ result ++ "'"); - } + const result = getDescription(input); + try testing.expectEqualStrings(expected, result); } } From 9e754f99390f48f9d59ee6884cd4215b8a029cbf Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 17 Nov 2025 21:31:02 -0700 Subject: [PATCH 021/209] perf: fix accidental overhead in refcountedset --- src/terminal/ref_counted_set.zig | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig index 651aaa3a0..70007f00d 100644 --- a/src/terminal/ref_counted_set.zig +++ b/src/terminal/ref_counted_set.zig @@ -549,9 +549,12 @@ pub fn RefCountedSet( } /// Insert the given value into the hash table with the given ID. - /// asserts that the value is not already present in the table. + /// + /// If runtime safety is enabled, asserts that + /// the value is not already present in the table. fn insert(self: *Self, base: anytype, value: T, new_id: Id, ctx: Context) Id { - assert(self.lookupContext(base, value, ctx) == null); + if (comptime std.debug.runtime_safety) + assert(self.lookupContext(base, value, ctx) == null); const table = self.table.ptr(base); const items = self.items.ptr(base); From 58c26957b4b92b913b600250ea78dba671f4ae1b Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 17 Nov 2025 21:32:48 -0700 Subject: [PATCH 022/209] perf: improve style hash and eql fns --- src/terminal/style.zig | 46 +++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/terminal/style.zig b/src/terminal/style.zig index f40d5350f..e5c47b9fe 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -54,6 +54,15 @@ pub const Style = struct { rgb, }; + /// True if the color is equal to another color. + pub fn eql(self: Color, other: Color) bool { + return @as(Tag, self) == @as(Tag, other) and switch (self) { + .none => true, + .palette => self.palette == other.palette, + .rgb => self.rgb == other.rgb, + }; + } + /// Formatting to make debug logs easier to read /// by only including non-default attributes. pub fn format( @@ -79,28 +88,16 @@ pub const Style = struct { }; /// True if the style is the default style. - pub fn default(self: Style) bool { + pub inline fn default(self: Style) bool { return self.eql(.{}); } /// True if the style is equal to another style. - /// For performance do direct comparisons first. pub fn eql(self: Style, other: Style) bool { - inline for (comptime std.meta.fields(Style)) |field| { - if (comptime std.meta.hasUniqueRepresentation(field.type)) { - if (@field(self, field.name) != @field(other, field.name)) { - return false; - } - } - } - inline for (comptime std.meta.fields(Style)) |field| { - if (comptime !std.meta.hasUniqueRepresentation(field.type)) { - if (!std.meta.eql(@field(self, field.name), @field(other, field.name))) { - return false; - } - } - } - return true; + return self.flags == other.flags and + self.fg_color.eql(other.fg_color) and + self.bg_color.eql(other.bg_color) and + self.underline_color.eql(other.underline_color); } /// Returns the bg color for a cell with this style given the cell @@ -509,12 +506,12 @@ pub const Style = struct { } }; - fn fromStyle(style: Style) PackedStyle { + inline fn fromStyle(style: Style) PackedStyle { return .{ .tags = .{ - .fg = std.meta.activeTag(style.fg_color), - .bg = std.meta.activeTag(style.bg_color), - .underline = std.meta.activeTag(style.underline_color), + .fg = @as(Color.Tag, style.fg_color), + .bg = @as(Color.Tag, style.bg_color), + .underline = @as(Color.Tag, style.underline_color), }, .data = .{ .fg = .fromColor(style.fg_color), @@ -527,8 +524,11 @@ pub const Style = struct { }; pub fn hash(self: *const Style) u64 { - const packed_style = PackedStyle.fromStyle(self.*); - return std.hash.XxHash3.hash(0, std.mem.asBytes(&packed_style)); + // We pack the style in to 128 bits, fold it to 64 bits, + // then use std.hash.int to make it sufficiently uniform. + const packed_style: PackedStyle = .fromStyle(self.*); + const wide: [2]u64 = @bitCast(packed_style); + return @call(.always_inline, std.hash.int, .{wide[0] ^ wide[1]}); } comptime { From 5a82e1b1190c65ef9502e331db02e7dcff47d34b Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Tue, 18 Nov 2025 23:19:59 +0800 Subject: [PATCH 023/209] build/blueprint: explicitly mention git vs tarballs I am so sick and tired of people complaining that the build instructions on the website are wrong when they clearly haven't realized the difference between Git-based and tarball-based builds, so here's the extra work to make sure people actually realize that --- src/apprt/gtk/build/blueprint.zig | 44 ++++++++++++++++--------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/src/apprt/gtk/build/blueprint.zig b/src/apprt/gtk/build/blueprint.zig index f25e7e1f9..4920ce6f8 100644 --- a/src/apprt/gtk/build/blueprint.zig +++ b/src/apprt/gtk/build/blueprint.zig @@ -11,6 +11,20 @@ pub const c = @cImport({ @cInclude("adwaita.h"); }); +pub const blueprint_compiler_help = + \\ + \\When building from a Git checkout, Ghostty requires + \\version {f} or newer of `blueprint-compiler` as a + \\build-time dependency. Please install it, ensure that it + \\is available on your PATH, and then retry building Ghostty. + \\See `HACKING.md` for more details. + \\ + \\This message should *not* appear for normal users, who + \\should build Ghostty from official release tarballs instead. + \\Please consult https://ghostty.org/docs/install/build for + \\more information on the recommended build instructions. +; + const adwaita_version = std.SemanticVersion{ .major = c.ADW_MAJOR_VERSION, .minor = c.ADW_MINOR_VERSION, @@ -79,13 +93,9 @@ pub fn main() !void { error.FileNotFound => { std.debug.print( \\`blueprint-compiler` not found. - \\ - \\Ghostty requires version {f} or newer of - \\`blueprint-compiler` as a build-time dependency starting - \\from version 1.2. Please install it, ensure that it is - \\available on your PATH, and then retry building Ghostty. - \\ - , .{required_blueprint_version}); + ++ blueprint_compiler_help, + .{required_blueprint_version}, + ); std.posix.exit(1); }, else => return err, @@ -103,13 +113,9 @@ pub fn main() !void { if (version.order(required_blueprint_version) == .lt) { std.debug.print( \\`blueprint-compiler` is the wrong version. - \\ - \\Ghostty requires version {f} or newer of - \\`blueprint-compiler` as a build-time dependency starting - \\from version 1.2. Please install it, ensure that it is - \\available on your PATH, and then retry building Ghostty. - \\ - , .{required_blueprint_version}); + ++ blueprint_compiler_help, + .{required_blueprint_version}, + ); std.posix.exit(1); } } @@ -144,13 +150,9 @@ pub fn main() !void { error.FileNotFound => { std.debug.print( \\`blueprint-compiler` not found. - \\ - \\Ghostty requires version {f} or newer of - \\`blueprint-compiler` as a build-time dependency starting - \\from version 1.2. Please install it, ensure that it is - \\available on your PATH, and then retry building Ghostty. - \\ - , .{required_blueprint_version}); + ++ blueprint_compiler_help, + .{required_blueprint_version}, + ); std.posix.exit(1); }, else => return err, From 3e8d94bb1c20f92fcafee9bac2c5203ea725f2da Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 17 Nov 2025 21:34:33 -0700 Subject: [PATCH 024/209] perf: misc inlines and branch hints Inlined trivial functions, added cold branch hints to error paths, added likely branch hints to common paths --- src/terminal/Parser.zig | 2 ++ src/terminal/Terminal.zig | 16 ++++++++++++++-- src/terminal/color.zig | 29 ++++++++++++++++++++++++----- src/terminal/osc.zig | 9 +++++++++ src/terminal/page.zig | 14 +++++++------- src/terminal/ref_counted_set.zig | 2 ++ 6 files changed, 58 insertions(+), 14 deletions(-) diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 612c93ee0..2a2e72a1d 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -312,6 +312,7 @@ pub fn next(self: *Parser, c: u8) [3]?Action { pub inline fn collect(self: *Parser, c: u8) void { if (self.intermediates_idx >= MAX_INTERMEDIATE) { + @branchHint(.cold); log.warn("invalid intermediates count", .{}); return; } @@ -386,6 +387,7 @@ inline fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { // We only allow colon or mixed separators for the 'm' command. if (c != 'm' and self.params_sep.count() > 0) { + @branchHint(.cold); log.warn( "CSI colon or mixed separators only allowed for 'm' command, got: {f}", .{result}, diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 8fa0e655d..fb5b67127 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -293,7 +293,10 @@ pub fn print(self: *Terminal, c: u21) !void { // log.debug("print={x} y={} x={}", .{ c, self.screens.active.cursor.y, self.screens.active.cursor.x }); // If we're not on the main display, do nothing for now - if (self.status_display != .main) return; + if (self.status_display != .main) { + @branchHint(.cold); + return; + } // After doing any printing, wrapping, scrolling, etc. we want to ensure // that our screen remains in a consistent state. @@ -313,6 +316,7 @@ pub fn print(self: *Terminal, c: u21) !void { self.modes.get(.grapheme_cluster) and self.screens.active.cursor.x > 0) grapheme: { + @branchHint(.unlikely); // We need the previous cell to determine if we're at a grapheme // break or not. If we are NOT, then we are still combining the // same grapheme. Otherwise, we can stay in this cell. @@ -478,6 +482,7 @@ pub fn print(self: *Terminal, c: u21) !void { // Attach zero-width characters to our cell as grapheme data. if (width == 0) { + @branchHint(.unlikely); // If we have grapheme clustering enabled, we don't blindly attach // any zero width character to our cells and we instead just ignore // it. @@ -535,6 +540,7 @@ pub fn print(self: *Terminal, c: u21) !void { switch (width) { // Single cell is very easy: just write in the cell 1 => { + @branchHint(.likely); self.screens.active.cursorMarkDirty(); @call(.always_inline, printCell, .{ self, c, .narrow }); }, @@ -602,10 +608,14 @@ fn printCell( self.screens.active.charset.single_shift = null; break :blk key_once; } else self.screens.active.charset.gl; + const set = self.screens.active.charset.charsets.get(key); // UTF-8 or ASCII is used as-is - if (set == .utf8 or set == .ascii) break :c unmapped_c; + if (set == .utf8 or set == .ascii) { + @branchHint(.likely); + break :c unmapped_c; + } // If we're outside of ASCII range this is an invalid value in // this table so we just return space. @@ -718,6 +728,7 @@ fn printCell( // row so that the renderer can lookup rows with these much faster. if (comptime build_options.kitty_graphics) { if (c == kitty.graphics.unicode.placeholder) { + @branchHint(.unlikely); self.screens.active.cursor.page_row.kitty_virtual_placeholder = true; } } @@ -727,6 +738,7 @@ fn printCell( // overwriting the same hyperlink. if (self.screens.active.cursor.hyperlink_id > 0) { self.screens.active.cursorSetHyperlink() catch |err| { + @branchHint(.unlikely); log.warn("error reallocating for more hyperlink space, ignoring hyperlink err={}", .{err}); assert(!cell.hyperlink); }; diff --git a/src/terminal/color.zig b/src/terminal/color.zig index ce7e9ce5d..07c3e72f5 100644 --- a/src/terminal/color.zig +++ b/src/terminal/color.zig @@ -356,8 +356,12 @@ pub const RGB = packed struct(u24) { /// /// The value should be between 0.0 and 1.0, inclusive. fn fromIntensity(value: []const u8) !u8 { - const i = std.fmt.parseFloat(f64, value) catch return error.InvalidFormat; + const i = std.fmt.parseFloat(f64, value) catch { + @branchHint(.cold); + return error.InvalidFormat; + }; if (i < 0.0 or i > 1.0) { + @branchHint(.cold); return error.InvalidFormat; } @@ -370,10 +374,15 @@ pub const RGB = packed struct(u24) { /// value scaled in 4, 8, 12, or 16 bits, respectively. fn fromHex(value: []const u8) !u8 { if (value.len == 0 or value.len > 4) { + @branchHint(.cold); return error.InvalidFormat; } - const color = std.fmt.parseUnsigned(u16, value, 16) catch return error.InvalidFormat; + const color = std.fmt.parseUnsigned(u16, value, 16) catch { + @branchHint(.cold); + return error.InvalidFormat; + }; + const divisor: usize = switch (value.len) { 1 => std.math.maxInt(u4), 2 => std.math.maxInt(u8), @@ -407,6 +416,7 @@ pub const RGB = packed struct(u24) { /// per color channel. pub fn parse(value: []const u8) !RGB { if (value.len == 0) { + @branchHint(.cold); return error.InvalidFormat; } @@ -433,7 +443,10 @@ pub const RGB = packed struct(u24) { .b = try RGB.fromHex(value[9..13]), }, - else => return error.InvalidFormat, + else => { + @branchHint(.cold); + return error.InvalidFormat; + }, } } @@ -443,6 +456,7 @@ pub const RGB = packed struct(u24) { if (x11_color.map.get(std.mem.trim(u8, value, " "))) |rgb| return rgb; if (value.len < "rgb:a/a/a".len or !std.mem.eql(u8, value[0..3], "rgb")) { + @branchHint(.cold); return error.InvalidFormat; } @@ -454,6 +468,7 @@ pub const RGB = packed struct(u24) { } else false; if (value[i] != ':') { + @branchHint(.cold); return error.InvalidFormat; } @@ -462,8 +477,10 @@ pub const RGB = packed struct(u24) { const r = r: { const slice = if (std.mem.indexOfScalarPos(u8, value, i, '/')) |end| value[i..end] - else + else { + @branchHint(.cold); return error.InvalidFormat; + }; i += slice.len + 1; @@ -476,8 +493,10 @@ pub const RGB = packed struct(u24) { const g = g: { const slice = if (std.mem.indexOfScalarPos(u8, value, i, '/')) |end| value[i..end] - else + else { + @branchHint(.cold); return error.InvalidFormat; + }; i += slice.len + 1; diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index ca212bae0..f62b7a6cd 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -524,6 +524,7 @@ pub const Parser = struct { // We always keep space for 1 byte at the end to null-terminate // values. if (self.buf_idx >= self.buf.len - 1) { + @branchHint(.cold); if (self.state != .invalid) { log.warn( "OSC sequence too long (> {d}), ignoring. state={}", @@ -1048,6 +1049,7 @@ pub const Parser = struct { ';' => { const ext = self.buf[self.buf_start .. self.buf_idx - 1]; if (!std.mem.eql(u8, ext, "notify")) { + @branchHint(.cold); log.warn("unknown rxvt extension: {s}", .{ext}); self.state = .invalid; return; @@ -1601,11 +1603,13 @@ pub const Parser = struct { fn endKittyColorProtocolOption(self: *Parser, kind: enum { key_only, key_and_value }, final: bool) void { if (self.temp_state.key.len == 0) { + @branchHint(.cold); log.warn("zero length key in kitty color protocol", .{}); return; } const key = kitty_color.Kind.parse(self.temp_state.key) orelse { + @branchHint(.cold); log.warn("unknown key in kitty color protocol: {s}", .{self.temp_state.key}); return; }; @@ -1620,6 +1624,7 @@ pub const Parser = struct { .kitty_color_protocol => |*v| { // Cap our allocation amount for our list. if (v.list.items.len >= @as(usize, kitty_color.Kind.max) * 2) { + @branchHint(.cold); self.state = .invalid; log.warn("exceeded limit for number of keys in kitty color protocol, ignoring", .{}); return; @@ -1631,11 +1636,13 @@ pub const Parser = struct { if (kind == .key_only or value.len == 0) { v.list.append(alloc, .{ .reset = key }) catch |err| { + @branchHint(.cold); log.warn("unable to append kitty color protocol option: {}", .{err}); return; }; } else if (mem.eql(u8, "?", value)) { v.list.append(alloc, .{ .query = key }) catch |err| { + @branchHint(.cold); log.warn("unable to append kitty color protocol option: {}", .{err}); return; }; @@ -1651,6 +1658,7 @@ pub const Parser = struct { }, }, }) catch |err| { + @branchHint(.cold); log.warn("unable to append kitty color protocol option: {}", .{err}); return; }; @@ -1681,6 +1689,7 @@ pub const Parser = struct { const alloc = self.alloc.?; const list = self.buf_dynamic.?; list.append(alloc, 0) catch { + @branchHint(.cold); log.warn("allocation failed on allocable string termination", .{}); self.temp_state.str.* = ""; return; diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 4b80aae45..6ed1db51a 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1896,7 +1896,7 @@ pub const Cell = packed struct(u64) { return cell; } - pub fn isZero(self: Cell) bool { + pub inline fn isZero(self: Cell) bool { return @as(u64, @bitCast(self)) == 0; } @@ -1906,7 +1906,7 @@ pub const Cell = packed struct(u64) { /// - Cell text is blank /// - Cell is styled but only with a background color and no text /// - Cell has a unicode placeholder for Kitty graphics protocol - pub fn hasText(self: Cell) bool { + pub inline fn hasText(self: Cell) bool { return switch (self.content_tag) { .codepoint, .codepoint_grapheme, @@ -1918,7 +1918,7 @@ pub const Cell = packed struct(u64) { }; } - pub fn codepoint(self: Cell) u21 { + pub inline fn codepoint(self: Cell) u21 { return switch (self.content_tag) { .codepoint, .codepoint_grapheme, @@ -1931,14 +1931,14 @@ pub const Cell = packed struct(u64) { } /// The width in grid cells that this cell takes up. - pub fn gridWidth(self: Cell) u2 { + pub inline fn gridWidth(self: Cell) u2 { return switch (self.wide) { .narrow, .spacer_head, .spacer_tail => 1, .wide => 2, }; } - pub fn hasStyling(self: Cell) bool { + pub inline fn hasStyling(self: Cell) bool { return self.style_id != stylepkg.default_id; } @@ -1957,12 +1957,12 @@ pub const Cell = packed struct(u64) { }; } - pub fn hasGrapheme(self: Cell) bool { + pub inline fn hasGrapheme(self: Cell) bool { return self.content_tag == .codepoint_grapheme; } /// Returns true if the set of cells has text in it. - pub fn hasTextAny(cells: []const Cell) bool { + pub inline fn hasTextAny(cells: []const Cell) bool { for (cells) |cell| { if (cell.hasText()) return true; } diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig index 70007f00d..25512bdaf 100644 --- a/src/terminal/ref_counted_set.zig +++ b/src/terminal/ref_counted_set.zig @@ -256,6 +256,7 @@ pub fn RefCountedSet( // we may end up with a PSL of `len` which would exceed the bounds. // In such a case, we claim to be out of memory. if (self.psl_stats[self.psl_stats.len - 1] > 0) { + @branchHint(.cold); return AddError.OutOfMemory; } @@ -308,6 +309,7 @@ pub fn RefCountedSet( if (items[id].meta.ref == 0) { // See comment in `addContext` for details. if (self.psl_stats[self.psl_stats.len - 1] > 0) { + @branchHint(.cold); return AddError.OutOfMemory; } From 14771e50093b1fe2ed4bbb439017b182c8e10875 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 17 Nov 2025 21:34:56 -0700 Subject: [PATCH 025/209] perf: avoid branch in parser csi param action --- src/terminal/Parser.zig | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 2a2e72a1d..69f7e859f 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -349,9 +349,7 @@ inline fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { } // A numeric value. Add it to our accumulator. - if (self.param_acc_idx > 0) { - self.param_acc *|= 10; - } + self.param_acc *|= 10; self.param_acc +|= c - '0'; // Increment our accumulator index. If we overflow then From 5744fb042cdb035cf33d728ed3d27cb2b4ba89a4 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 17 Nov 2025 21:35:38 -0700 Subject: [PATCH 026/209] perf: replace charset EnumArray with bespoke struct --- src/terminal/Screen.zig | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 789ba90b0..2f35fc5ed 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -161,7 +161,7 @@ pub const SavedCursor = struct { /// State required for all charset operations. pub const CharsetState = struct { /// The list of graphical charsets by slot - charsets: CharsetArray = .initFill(charsets.Charset.utf8), + charsets: CharsetArray = .{}, /// GL is the slot to use when using a 7-bit printable char (up to 127) /// GR used for 8-bit printable chars. @@ -172,7 +172,41 @@ pub const CharsetState = struct { single_shift: ?charsets.Slots = null, /// An array to map a charset slot to a lookup table. - const CharsetArray = std.EnumArray(charsets.Slots, charsets.Charset); + /// + /// We use this bespoke struct instead of `std.EnumArray` because + /// accessing these slots is very performance critical since it's + /// done for every single print. This benchmarks faster. + const CharsetArray = struct { + g0: charsets.Charset = .utf8, + g1: charsets.Charset = .utf8, + g2: charsets.Charset = .utf8, + g3: charsets.Charset = .utf8, + + pub inline fn get( + self: *const CharsetArray, + slot: charsets.Slots, + ) charsets.Charset { + return switch (slot) { + .G0 => self.g0, + .G1 => self.g1, + .G2 => self.g2, + .G3 => self.g3, + }; + } + + pub inline fn set( + self: *CharsetArray, + slot: charsets.Slots, + charset: charsets.Charset, + ) void { + switch (slot) { + .G0 => self.g0 = charset, + .G1 => self.g1 = charset, + .G2 => self.g2 = charset, + .G3 => self.g3 = charset, + } + } + }; }; pub const Options = struct { From 212598ed660630113f84300f18a58e58fe0b0475 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 18 Nov 2025 11:31:17 -0700 Subject: [PATCH 027/209] perf: add branch hints based on real world data + move stream ESC state entry outside of `nextNonUtf8` --- src/terminal/stream.zig | 538 ++++++++++++++++++++++------------ src/termio/stream_handler.zig | 54 +++- 2 files changed, 398 insertions(+), 194 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 9db1dc60b..ba6b57d5c 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -645,6 +645,11 @@ pub fn Stream(comptime Handler: type) type { try self.handleCodepoint(codepoint); } if (!consumed) { + // We optimize for the scenario where the text being + // printed in the terminal ISN'T full of ill-formed + // UTF-8 sequences. + @branchHint(.unlikely); + const retry = self.utf8decoder.next(c); // It should be impossible for the decoder // to not consume the byte twice in a row. @@ -665,12 +670,16 @@ pub fn Stream(comptime Handler: type) type { // a chain of inline functions. @setEvalBranchQuota(100_000); + // C0 control if (c <= 0xF) { + @branchHint(.unlikely); try self.execute(@intCast(c)); return; } + // ESC if (c == 0x1B) { - try self.nextNonUtf8(@intCast(c)); + self.parser.state = .escape; + self.parser.clear(); return; } try self.print(@intCast(c)); @@ -681,14 +690,8 @@ pub fn Stream(comptime Handler: type) type { /// This assumes that we're not in the UTF-8 decoding state. If /// we may be in the UTF-8 decoding state call nextSlice or next. fn nextNonUtf8(self: *Self, c: u8) !void { - assert(self.parser.state != .ground or c == 0x1B); + assert(self.parser.state != .ground); - // Fast path for ESC - if (self.parser.state == .ground and c == 0x1B) { - self.parser.state = .escape; - self.parser.clear(); - return; - } // Fast path for CSI entry. if (self.parser.state == .escape and c == '[') { self.parser.state = .csi_entry; @@ -696,6 +699,11 @@ pub fn Stream(comptime Handler: type) type { } // Fast path for CSI params. if (self.parser.state == .csi_param) csi_param: { + // csi_param is the most common parser state + // other than ground by a fairly wide margin. + // + // ref: https://github.com/qwerasd205/asciinema-stats + @branchHint(.likely); switch (c) { // A C0 escape (yes, this is valid): 0x00...0x0F => try self.execute(c), @@ -814,24 +822,52 @@ pub fn Stream(comptime Handler: type) type { } inline fn csiDispatch(self: *Self, input: Parser.Action.CSI) !void { + // The branch hints here are based on real world data + // which indicates that the most common CSI finals are: + // + // 1. m + // 2. H + // 3. K + // 4. A + // 5. C + // 6. X + // 7. l + // 8. h + // 9. r + // + // Together, these 9 finals make up about 96% of all + // CSI sequences encountered in real world scenarios. + // + // Additionally, within the prongs, unlikely branch + // hints have been added to branches that deal with + // invalid sequences/commands, this is in order to + // optimize for the happy path where we're getting + // valid data from the program we're running. + // + // ref: https://github.com/qwerasd205/asciinema-stats + switch (input.final) { // CUU - Cursor Up - 'A', 'k' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.cursor_up, .{ - .value = switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor up command: {f}", .{input}); - return; + 'A', 'k' => { + @branchHint(.likely); + switch (input.intermediates.len) { + 0 => try self.handler.vt(.cursor_up, .{ + .value = switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + @branchHint(.unlikely); + log.warn("invalid cursor up command: {f}", .{input}); + return; + }, }, - }, - }), + }), - else => log.warn( - "ignoring unimplemented CSI A with intermediates: {s}", - .{input.intermediates}, - ), + else => log.warn( + "ignoring unimplemented CSI A with intermediates: {s}", + .{input.intermediates}, + ), + } }, // CUD - Cursor Down @@ -841,6 +877,7 @@ pub fn Stream(comptime Handler: type) type { 0 => 1, 1 => input.params[0], else => { + @branchHint(.unlikely); log.warn("invalid cursor down command: {f}", .{input}); return; }, @@ -854,22 +891,26 @@ pub fn Stream(comptime Handler: type) type { }, // CUF - Cursor Right - 'C' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.cursor_right, .{ - .value = switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor right command: {f}", .{input}); - return; + 'C' => { + @branchHint(.likely); + switch (input.intermediates.len) { + 0 => try self.handler.vt(.cursor_right, .{ + .value = switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + @branchHint(.unlikely); + log.warn("invalid cursor right command: {f}", .{input}); + return; + }, }, - }, - }), + }), - else => log.warn( - "ignoring unimplemented CSI C with intermediates: {s}", - .{input.intermediates}, - ), + else => log.warn( + "ignoring unimplemented CSI C with intermediates: {s}", + .{input.intermediates}, + ), + } }, // CUB - Cursor Left @@ -879,6 +920,7 @@ pub fn Stream(comptime Handler: type) type { 0 => 1, 1 => input.params[0], else => { + @branchHint(.unlikely); log.warn("invalid cursor left command: {f}", .{input}); return; }, @@ -899,6 +941,7 @@ pub fn Stream(comptime Handler: type) type { 0 => 1, 1 => input.params[0], else => { + @branchHint(.unlikely); log.warn("invalid cursor up command: {f}", .{input}); return; }, @@ -921,6 +964,7 @@ pub fn Stream(comptime Handler: type) type { 0 => 1, 1 => input.params[0], else => { + @branchHint(.unlikely); log.warn("invalid cursor down command: {f}", .{input}); return; }, @@ -943,6 +987,7 @@ pub fn Stream(comptime Handler: type) type { 0 => 1, 1 => input.params[0], else => { + @branchHint(.unlikely); log.warn("invalid HPA command: {f}", .{input}); return; }, @@ -957,24 +1002,28 @@ pub fn Stream(comptime Handler: type) type { // CUP - Set Cursor Position. // TODO: test - 'H', 'f' => switch (input.intermediates.len) { - 0 => { - const pos: streampkg.Action.CursorPos = switch (input.params.len) { - 0 => .{ .row = 1, .col = 1 }, - 1 => .{ .row = input.params[0], .col = 1 }, - 2 => .{ .row = input.params[0], .col = input.params[1] }, - else => { - log.warn("invalid CUP command: {f}", .{input}); - return; - }, - }; - try self.handler.vt(.cursor_pos, pos); - }, + 'H', 'f' => { + @branchHint(.likely); + switch (input.intermediates.len) { + 0 => { + const pos: streampkg.Action.CursorPos = switch (input.params.len) { + 0 => .{ .row = 1, .col = 1 }, + 1 => .{ .row = input.params[0], .col = 1 }, + 2 => .{ .row = input.params[0], .col = input.params[1] }, + else => { + @branchHint(.unlikely); + log.warn("invalid CUP command: {f}", .{input}); + return; + }, + }; + try self.handler.vt(.cursor_pos, pos); + }, - else => log.warn( - "ignoring unimplemented CSI H with intermediates: {s}", - .{input.intermediates}, - ), + else => log.warn( + "ignoring unimplemented CSI H with intermediates: {s}", + .{input.intermediates}, + ), + } }, // CHT - Cursor Horizontal Tabulation @@ -1029,6 +1078,7 @@ pub fn Stream(comptime Handler: type) type { // Erase Line 'K' => { + @branchHint(.likely); const protected_: ?bool = switch (input.intermediates.len) { 0 => false, 1 => if (input.intermediates[0] == '?') true else null, @@ -1036,6 +1086,7 @@ pub fn Stream(comptime Handler: type) type { }; const protected = protected_ orelse { + @branchHint(.unlikely); log.warn("invalid erase line command: {f}", .{input}); return; }; @@ -1047,6 +1098,7 @@ pub fn Stream(comptime Handler: type) type { }; const mode = mode_ orelse { + @branchHint(.unlikely); log.warn("invalid erase line command: {f}", .{input}); return; }; @@ -1056,7 +1108,10 @@ pub fn Stream(comptime Handler: type) type { .left => try self.handler.vt(.erase_line_left, protected), .complete => try self.handler.vt(.erase_line_complete, protected), .right_unless_pending_wrap => try self.handler.vt(.erase_line_right_unless_pending_wrap, protected), - _ => log.warn("invalid erase line mode: {}", .{mode}), + _ => { + @branchHint(.unlikely); + log.warn("invalid erase line mode: {}", .{mode}); + }, } }, @@ -1189,20 +1244,24 @@ pub fn Stream(comptime Handler: type) type { }, // Erase Characters (ECH) - 'X' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.erase_chars, switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid erase characters command: {f}", .{input}); - return; - }, - }), + 'X' => { + @branchHint(.likely); + switch (input.intermediates.len) { + 0 => try self.handler.vt(.erase_chars, switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + @branchHint(.unlikely); + log.warn("invalid erase characters command: {f}", .{input}); + return; + }, + }), - else => log.warn( - "ignoring unimplemented CSI X with intermediates: {s}", - .{input.intermediates}, - ), + else => log.warn( + "ignoring unimplemented CSI X with intermediates: {s}", + .{input.intermediates}, + ), + } }, // CHT - Cursor Horizontal Tabulation Back @@ -1342,6 +1401,7 @@ pub fn Stream(comptime Handler: type) type { // SM - Set Mode 'h' => mode: { + @branchHint(.likely); const ansi_mode = ansi: { if (input.intermediates.len == 0) break :ansi true; if (input.intermediates.len == 1 and @@ -1362,6 +1422,7 @@ pub fn Stream(comptime Handler: type) type { // RM - Reset Mode 'l' => mode: { + @branchHint(.likely); const ansi_mode = ansi: { if (input.intermediates.len == 0) break :ansi true; if (input.intermediates.len == 1 and @@ -1381,81 +1442,86 @@ pub fn Stream(comptime Handler: type) type { }, // SGR - Select Graphic Rendition - 'm' => switch (input.intermediates.len) { - 0 => { - // log.info("parse SGR params={any}", .{input.params}); - var p: sgr.Parser = .{ - .params = input.params, - .params_sep = input.params_sep, - }; - while (p.next()) |attr| { - // log.info("SGR attribute: {}", .{attr}); - try self.handler.vt(.set_attribute, attr); - } - }, - - 1 => switch (input.intermediates[0]) { - '>' => blk: { - if (input.params.len == 0) { - // Reset - try self.handler.vt(.modify_key_format, .legacy); - break :blk; - } - - var format: ansi.ModifyKeyFormat = switch (input.params[0]) { - 0 => .legacy, - 1 => .cursor_keys, - 2 => .function_keys, - 4 => .other_keys_none, - else => { - log.warn("invalid setModifyKeyFormat: {f}", .{input}); - break :blk; - }, + 'm' => { + @branchHint(.likely); + switch (input.intermediates.len) { + 0 => { + // This is the most common case. + @branchHint(.likely); + // log.info("parse SGR params={any}", .{input.params}); + var p: sgr.Parser = .{ + .params = input.params, + .params_sep = input.params_sep, }; - - if (input.params.len > 2) { - log.warn("invalid setModifyKeyFormat: {f}", .{input}); - break :blk; + while (p.next()) |attr| { + // log.info("SGR attribute: {}", .{attr}); + try self.handler.vt(.set_attribute, attr); } - - if (input.params.len == 2) { - switch (format) { - // We don't support any of the subparams yet for these. - .legacy => {}, - .cursor_keys => {}, - .function_keys => {}, - - // We only support the numeric form. - .other_keys_none => switch (input.params[1]) { - 2 => format = .other_keys_numeric, - else => {}, - }, - .other_keys_numeric_except => {}, - .other_keys_numeric => {}, - } - } - - try self.handler.vt(.modify_key_format, format); }, - else => log.warn( - "unknown CSI m with intermediate: {}", - .{input.intermediates[0]}, - ), - }, + 1 => switch (input.intermediates[0]) { + '>' => blk: { + if (input.params.len == 0) { + // Reset + try self.handler.vt(.modify_key_format, .legacy); + break :blk; + } - else => { - // Nothing, but I wanted a place to put this comment: - // there are others forms of CSI m that have intermediates. - // `vim --clean` uses `CSI ? 4 m` and I don't know what - // that means. And there is also `CSI > m` which is used - // to control modifier key reporting formats that we don't - // support yet. - log.warn( - "ignoring unimplemented CSI m with intermediates: {s}", - .{input.intermediates}, - ); - }, + var format: ansi.ModifyKeyFormat = switch (input.params[0]) { + 0 => .legacy, + 1 => .cursor_keys, + 2 => .function_keys, + 4 => .other_keys_none, + else => { + @branchHint(.unlikely); + log.warn("invalid setModifyKeyFormat: {f}", .{input}); + break :blk; + }, + }; + + if (input.params.len > 2) { + @branchHint(.unlikely); + log.warn("invalid setModifyKeyFormat: {f}", .{input}); + break :blk; + } + + if (input.params.len == 2) { + switch (format) { + // We don't support any of the subparams yet for these. + .legacy => {}, + .cursor_keys => {}, + .function_keys => {}, + + // We only support the numeric form. + .other_keys_none => switch (input.params[1]) { + 2 => format = .other_keys_numeric, + else => {}, + }, + .other_keys_numeric_except => {}, + .other_keys_numeric => {}, + } + } + + try self.handler.vt(.modify_key_format, format); + }, + + else => log.warn( + "unknown CSI m with intermediate: {}", + .{input.intermediates[0]}, + ), + }, + + else => { + // Nothing, but I wanted a place to put this comment: + // there are others forms of CSI m that have intermediates. + // `vim --clean` uses `CSI ? 4 m` and I don't know what + // that means. + log.warn( + "ignoring unimplemented CSI m with intermediates: {s}", + .{input.intermediates}, + ); + }, + } }, // TODO: test @@ -1622,40 +1688,46 @@ pub fn Stream(comptime Handler: type) type { ), }, - 'r' => switch (input.intermediates.len) { - // DECSTBM - Set Top and Bottom Margins - 0 => switch (input.params.len) { - 0 => try self.handler.vt(.top_and_bottom_margin, .{ .top_left = 0, .bottom_right = 0 }), - 1 => try self.handler.vt(.top_and_bottom_margin, .{ .top_left = input.params[0], .bottom_right = 0 }), - 2 => try self.handler.vt(.top_and_bottom_margin, .{ .top_left = input.params[0], .bottom_right = input.params[1] }), - else => log.warn("invalid DECSTBM command: {f}", .{input}), - }, + 'r' => { + @branchHint(.likely); + switch (input.intermediates.len) { + // DECSTBM - Set Top and Bottom Margins + 0 => switch (input.params.len) { + 0 => try self.handler.vt(.top_and_bottom_margin, .{ .top_left = 0, .bottom_right = 0 }), + 1 => try self.handler.vt(.top_and_bottom_margin, .{ .top_left = input.params[0], .bottom_right = 0 }), + 2 => try self.handler.vt(.top_and_bottom_margin, .{ .top_left = input.params[0], .bottom_right = input.params[1] }), + else => { + @branchHint(.unlikely); + log.warn("invalid DECSTBM command: {f}", .{input}); + }, + }, - 1 => switch (input.intermediates[0]) { - // Restore Mode - '?' => { - for (input.params) |mode_int| { - if (modes.modeFromInt(mode_int, false)) |mode| { - try self.handler.vt(.restore_mode, .{ .mode = mode }); - } else { - log.warn( - "unimplemented restore mode: {}", - .{mode_int}, - ); + 1 => switch (input.intermediates[0]) { + // Restore Mode + '?' => { + for (input.params) |mode_int| { + if (modes.modeFromInt(mode_int, false)) |mode| { + try self.handler.vt(.restore_mode, .{ .mode = mode }); + } else { + log.warn( + "unimplemented restore mode: {}", + .{mode_int}, + ); + } } - } + }, + + else => log.warn( + "unknown CSI s with intermediate: {f}", + .{input}, + ), }, else => log.warn( - "unknown CSI s with intermediate: {f}", + "ignoring unimplemented CSI s with intermediates: {f}", .{input}, ), - }, - - else => log.warn( - "ignoring unimplemented CSI s with intermediates: {f}", - .{input}, - ), + } }, 's' => switch (input.intermediates.len) { @@ -1866,6 +1938,7 @@ pub fn Stream(comptime Handler: type) type { 0 => 1, 1 => input.params[0], else => { + @branchHint(.unlikely); log.warn("invalid ICH command: {f}", .{input}); return; }, @@ -1906,9 +1979,34 @@ pub fn Stream(comptime Handler: type) type { } inline fn oscDispatch(self: *Self, cmd: osc.Command) !void { + // The branch hints here are based on real world data + // which indicates that the most common OSC commands are: + // + // 1. hyperlink_end + // 2. change_window_title + // 3. change_window_icon + // 4. hyperlink_start + // 5. report_pwd + // 6. color_operation + // 7. prompt_start + // 8. prompt_end + // + // Together, these 8 commands make up about 96% of all + // OSC commands encountered in real world scenarios. + // + // Additionally, within the prongs, unlikely branch + // hints have been added to branches that deal with + // invalid sequences/commands, this is in order to + // optimize for the happy path where we're getting + // valid data from the program we're running. + // + // ref: https://github.com/qwerasd205/asciinema-stats + switch (cmd) { .change_window_title => |title| { + @branchHint(.likely); if (!std.unicode.utf8ValidateSlice(title)) { + @branchHint(.unlikely); log.warn("change title request: invalid utf-8, ignoring request", .{}); return; } @@ -1917,6 +2015,7 @@ pub fn Stream(comptime Handler: type) type { }, .change_window_icon => |icon| { + @branchHint(.likely); log.info("OSC 1 (change icon) received and ignored icon={s}", .{icon}); }, @@ -1928,6 +2027,7 @@ pub fn Stream(comptime Handler: type) type { }, .prompt_start => |v| { + @branchHint(.likely); switch (v.kind) { .primary, .right => try self.handler.vt(.prompt_start, .{ .aid = v.aid, @@ -1939,7 +2039,10 @@ pub fn Stream(comptime Handler: type) type { } }, - .prompt_end => try self.handler.vt(.prompt_end, {}), + .prompt_end => { + @branchHint(.likely); + try self.handler.vt(.prompt_end, {}); + }, .end_of_input => try self.handler.vt(.end_of_input, {}), @@ -1948,11 +2051,13 @@ pub fn Stream(comptime Handler: type) type { }, .report_pwd => |v| { + @branchHint(.likely); try self.handler.vt(.report_pwd, .{ .url = v.value }); }, .mouse_shape => |v| { const shape = MouseShape.fromString(v.value) orelse { + @branchHint(.unlikely); log.warn("unknown cursor shape: {s}", .{v.value}); return; }; @@ -1961,6 +2066,7 @@ pub fn Stream(comptime Handler: type) type { }, .color_operation => |v| { + @branchHint(.likely); try self.handler.vt(.color_operation, .{ .op = v.op, .requests = v.requests, @@ -1980,6 +2086,7 @@ pub fn Stream(comptime Handler: type) type { }, .hyperlink_start => |v| { + @branchHint(.likely); try self.handler.vt(.start_hyperlink, .{ .uri = v.uri, .id = v.id, @@ -1987,6 +2094,7 @@ pub fn Stream(comptime Handler: type) type { }, .hyperlink_end => { + @branchHint(.likely); try self.handler.vt(.end_hyperlink, {}); }, @@ -2004,6 +2112,7 @@ pub fn Stream(comptime Handler: type) type { }, .invalid => { + @branchHint(.cold); // This is an invalid internal state, not an invalid OSC // string being parsed. We shouldn't see this. log.warn("invalid OSC, should never happen", .{}); @@ -2029,6 +2138,7 @@ pub fn Stream(comptime Handler: type) type { '*' => .G2, '+' => .G3, else => { + @branchHint(.unlikely); log.warn("invalid charset intermediate: {any}", .{intermediates}); return; }, @@ -2044,22 +2154,56 @@ pub fn Stream(comptime Handler: type) type { self: *Self, action: Parser.Action.ESC, ) !void { + // The branch hints here are based on real world data + // which indicates that the most common ESC finals are: + // + // 1. B + // 2. \ + // 3. 0 + // 4. M + // 5. 8 + // 6. 7 + // 7. > + // 8. = + // + // Together, these 8 finals make up nearly 99% of all + // ESC sequences encountered in real world scenarios. + // + // Additionally, within the prongs, unlikely branch + // hints have been added to branches that deal with + // invalid sequences/commands, this is in order to + // optimize for the happy path where we're getting + // valid data from the program we're running. + // + // ref: https://github.com/qwerasd205/asciinema-stats + switch (action.final) { // Charsets - 'B' => try self.configureCharset(action.intermediates, .ascii), + 'B' => { + @branchHint(.likely); + try self.configureCharset(action.intermediates, .ascii); + }, 'A' => try self.configureCharset(action.intermediates, .british), - '0' => try self.configureCharset(action.intermediates, .dec_special), + '0' => { + @branchHint(.likely); + try self.configureCharset(action.intermediates, .dec_special); + }, // DECSC - Save Cursor - '7' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.save_cursor, {}), - else => { - log.warn("invalid command: {f}", .{action}); - return; - }, + '7' => { + @branchHint(.likely); + switch (action.intermediates.len) { + 0 => try self.handler.vt(.save_cursor, {}), + else => { + @branchHint(.unlikely); + log.warn("invalid command: {f}", .{action}); + return; + }, + } }, '8' => blk: { + @branchHint(.likely); switch (action.intermediates.len) { // DECRC - Restore Cursor 0 => { @@ -2087,6 +2231,7 @@ pub fn Stream(comptime Handler: type) type { 'D' => switch (action.intermediates.len) { 0 => try self.handler.vt(.index, {}), else => { + @branchHint(.unlikely); log.warn("invalid index command: {f}", .{action}); return; }, @@ -2096,6 +2241,7 @@ pub fn Stream(comptime Handler: type) type { 'E' => switch (action.intermediates.len) { 0 => try self.handler.vt(.next_line, {}), else => { + @branchHint(.unlikely); log.warn("invalid next line command: {f}", .{action}); return; }, @@ -2105,18 +2251,23 @@ pub fn Stream(comptime Handler: type) type { 'H' => switch (action.intermediates.len) { 0 => try self.handler.vt(.tab_set, {}), else => { + @branchHint(.unlikely); log.warn("invalid tab set command: {f}", .{action}); return; }, }, // RI - Reverse Index - 'M' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.reverse_index, {}), - else => { - log.warn("invalid reverse index command: {f}", .{action}); - return; - }, + 'M' => { + @branchHint(.likely); + switch (action.intermediates.len) { + 0 => try self.handler.vt(.reverse_index, {}), + else => { + @branchHint(.unlikely); + log.warn("invalid reverse index command: {f}", .{action}); + return; + }, + } }, // SS2 - Single Shift 2 @@ -2127,6 +2278,7 @@ pub fn Stream(comptime Handler: type) type { .locking = true, }), else => { + @branchHint(.unlikely); log.warn("invalid single shift 2 command: {f}", .{action}); return; }, @@ -2140,6 +2292,7 @@ pub fn Stream(comptime Handler: type) type { .locking = true, }), else => { + @branchHint(.unlikely); log.warn("invalid single shift 3 command: {f}", .{action}); return; }, @@ -2179,6 +2332,7 @@ pub fn Stream(comptime Handler: type) type { .locking = false, }), else => { + @branchHint(.unlikely); log.warn("invalid single shift 2 command: {f}", .{action}); return; }, @@ -2192,6 +2346,7 @@ pub fn Stream(comptime Handler: type) type { .locking = false, }), else => { + @branchHint(.unlikely); log.warn("invalid single shift 3 command: {f}", .{action}); return; }, @@ -2205,6 +2360,7 @@ pub fn Stream(comptime Handler: type) type { .locking = false, }), else => { + @branchHint(.unlikely); log.warn("invalid locking shift 1 right command: {f}", .{action}); return; }, @@ -2218,6 +2374,7 @@ pub fn Stream(comptime Handler: type) type { .locking = false, }), else => { + @branchHint(.unlikely); log.warn("invalid locking shift 2 right command: {f}", .{action}); return; }, @@ -2231,26 +2388,35 @@ pub fn Stream(comptime Handler: type) type { .locking = false, }), else => { + @branchHint(.unlikely); log.warn("invalid locking shift 3 right command: {f}", .{action}); return; }, }, // Set application keypad mode - '=' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.set_mode, .{ .mode = .keypad_keys }), - else => log.warn("unimplemented setMode: {f}", .{action}), + '=' => { + @branchHint(.likely); + switch (action.intermediates.len) { + 0 => try self.handler.vt(.set_mode, .{ .mode = .keypad_keys }), + else => log.warn("unimplemented setMode: {f}", .{action}), + } }, // Reset application keypad mode - '>' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.reset_mode, .{ .mode = .keypad_keys }), - else => log.warn("unimplemented setMode: {f}", .{action}), + '>' => { + @branchHint(.likely); + switch (action.intermediates.len) { + 0 => try self.handler.vt(.reset_mode, .{ .mode = .keypad_keys }), + else => log.warn("unimplemented setMode: {f}", .{action}), + } }, // Sets ST (string terminator). We don't have to do anything // because our parser always accepts ST. - '\\' => {}, + '\\' => { + @branchHint(.likely); + }, else => log.warn("unimplemented ESC action: {f}", .{action}), } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 431aa8bdd..6e125e100 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -165,22 +165,47 @@ pub const StreamHandler = struct { comptime action: Stream.Action.Tag, value: Stream.Action.Value(action), ) !void { + // The branch hints here are based on real world data + // which indicates that the most common actions are: + // + // 1. print + // 2. set_attribute + // 3. carriage_return + // 4. line_feed + // 5. cursor_pos + // + // Together, these 5 actions make up nearly 98% of + // all actions encountered in real world scenarios. + // + // ref: https://github.com/qwerasd205/asciinema-stats switch (action) { - .print => try self.terminal.print(value.cp), + .print => { + @branchHint(.likely); + try self.terminal.print(value.cp); + }, .print_repeat => try self.terminal.printRepeat(value), .bell => self.bell(), .backspace => self.terminal.backspace(), .horizontal_tab => try self.horizontalTab(value), .horizontal_tab_back => try self.horizontalTabBack(value), - .linefeed => try self.linefeed(), - .carriage_return => self.terminal.carriageReturn(), + .linefeed => { + @branchHint(.likely); + try self.linefeed(); + }, + .carriage_return => { + @branchHint(.likely); + self.terminal.carriageReturn(); + }, .enquiry => try self.enquiry(), .invoke_charset => self.terminal.invokeCharset(value.bank, value.charset, value.locking), .cursor_up => self.terminal.cursorUp(value.value), .cursor_down => self.terminal.cursorDown(value.value), .cursor_left => self.terminal.cursorLeft(value.value), .cursor_right => self.terminal.cursorRight(value.value), - .cursor_pos => self.terminal.setCursorPos(value.row, value.col), + .cursor_pos => { + @branchHint(.likely); + self.terminal.setCursorPos(value.row, value.col); + }, .cursor_col => self.terminal.setCursorPos(self.terminal.screens.active.cursor.y + 1, value.value), .cursor_row => self.terminal.setCursorPos(value.value, self.terminal.screens.active.cursor.x + 1), .cursor_col_relative => self.terminal.setCursorPos( @@ -290,10 +315,23 @@ pub const StreamHandler = struct { .end_of_command => self.endOfCommand(value.exit_code), .mouse_shape => try self.setMouseShape(value), .configure_charset => self.configureCharset(value.slot, value.charset), - .set_attribute => switch (value) { - .unknown => |unk| log.warn("unimplemented or unknown SGR attribute: {any}", .{unk}), - else => self.terminal.setAttribute(value) catch |err| - log.warn("error setting attribute {}: {}", .{ value, err }), + .set_attribute => { + @branchHint(.likely); + switch (value) { + .unknown => |unk| { + // We optimize for the happy path scenario here, since + // unknown/invalid SGRs aren't that common in the wild. + @branchHint(.unlikely); + log.warn("unimplemented or unknown SGR attribute: {any}", .{unk}); + }, + else => { + @branchHint(.likely); + self.terminal.setAttribute(value) catch |err| { + @branchHint(.cold); + log.warn("error setting attribute {}: {}", .{ value, err }); + }; + }, + } }, .dcs_hook => try self.dcsHook(value), .dcs_put => try self.dcsPut(value), From 30472c007792354e80dac05f5c56e8ca7348e768 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 18 Nov 2025 12:10:47 -0700 Subject: [PATCH 028/209] perf: replace dirty bitset with a flag on each row This is much faster for most operations since the row is often already loaded when we have to mark it as dirty. --- src/renderer/generic.zig | 7 +-- src/terminal/PageList.zig | 54 +++++++++---------- src/terminal/Screen.zig | 34 ++++++------ src/terminal/Terminal.zig | 6 +++ src/terminal/page.zig | 111 ++++++++------------------------------ 5 files changed, 74 insertions(+), 138 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index ac4cd95a2..9fafc5a48 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1191,12 +1191,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { { var it = state.terminal.screens.active.pages.pageIterator( .right_down, - .{ .screen = .{} }, + .{ .viewport = .{} }, null, ); while (it.next()) |chunk| { - var dirty_set = chunk.node.data.dirtyBitSet(); - dirty_set.unsetAll(); + for (chunk.rows()) |*row| { + row.dirty = false; + } } } diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 98cc1a9f3..5217e30bd 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2683,10 +2683,9 @@ pub fn eraseRow( // If we have a pinned viewport, we need to adjust for active area. self.fixupViewport(1); - { - // Set all the rows as dirty in this page - var dirty = node.data.dirtyBitSet(); - dirty.setRangeValue(.{ .start = pn.y, .end = node.data.size.rows }, true); + // Set all the rows as dirty in this page, starting at the erased row. + for (rows[pn.y..node.data.size.rows]) |*row| { + row.dirty = true; } // We iterate through all of the following pages in order to move their @@ -2721,8 +2720,9 @@ pub fn eraseRow( fastmem.rotateOnce(Row, rows[0..node.data.size.rows]); // Set all the rows as dirty - var dirty = node.data.dirtyBitSet(); - dirty.setRangeValue(.{ .start = 0, .end = node.data.size.rows }, true); + for (rows[0..node.data.size.rows]) |*row| { + row.dirty = true; + } // Our tracked pins for this page need to be updated. // If the pin is in row 0 that means the corresponding row has @@ -2774,8 +2774,9 @@ pub fn eraseRowBounded( fastmem.rotateOnce(Row, rows[pn.y..][0 .. limit + 1]); // Set all the rows as dirty - var dirty = node.data.dirtyBitSet(); - dirty.setRangeValue(.{ .start = pn.y, .end = pn.y + limit }, true); + for (rows[pn.y..][0..limit]) |*row| { + row.dirty = true; + } // If our viewport is a pin and our pin is within the erased // region we need to maybe shift our cache up. We do this here instead @@ -2813,9 +2814,8 @@ pub fn eraseRowBounded( fastmem.rotateOnce(Row, rows[pn.y..node.data.size.rows]); // All the rows in the page are dirty below the erased row. - { - var dirty = node.data.dirtyBitSet(); - dirty.setRangeValue(.{ .start = pn.y, .end = node.data.size.rows }, true); + for (rows[pn.y..node.data.size.rows]) |*row| { + row.dirty = true; } // We need to keep track of how many rows we've shifted so that we can @@ -2872,8 +2872,9 @@ pub fn eraseRowBounded( fastmem.rotateOnce(Row, rows[0 .. shifted_limit + 1]); // Set all the rows as dirty - var dirty = node.data.dirtyBitSet(); - dirty.setRangeValue(.{ .start = 0, .end = shifted_limit }, true); + for (rows[0..shifted_limit]) |*row| { + row.dirty = true; + } // See the other places we do something similar in this function // for a detailed explanation. @@ -2904,8 +2905,9 @@ pub fn eraseRowBounded( fastmem.rotateOnce(Row, rows[0..node.data.size.rows]); // Set all the rows as dirty - var dirty = node.data.dirtyBitSet(); - dirty.setRangeValue(.{ .start = 0, .end = node.data.size.rows }, true); + for (rows[0..node.data.size.rows]) |*row| { + row.dirty = true; + } // Account for the rows shifted in this node. shifted += node.data.size.rows; @@ -2993,6 +2995,9 @@ pub fn eraseRows( const old_dst = dst.*; dst.* = src.*; src.* = old_dst; + + // Mark the moved row as dirty. + dst.dirty = true; } // Clear our remaining cells that we didn't shift or swapped @@ -3022,10 +3027,6 @@ pub fn eraseRows( // Our new size is the amount we scrolled chunk.node.data.size.rows = @intCast(scroll_amount); erased += chunk.end; - - // Set all the rows as dirty - var dirty = chunk.node.data.dirtyBitSet(); - dirty.setRangeValue(.{ .start = 0, .end = chunk.node.data.size.rows }, true); } // Update our total row count @@ -3881,10 +3882,10 @@ fn growRows(self: *PageList, n: usize) !void { /// traverses the entire list of pages. This is used for testing/debugging. pub fn clearDirty(self: *PageList) void { var page = self.pages.first; - while (page) |p| { - var set = p.data.dirtyBitSet(); - set.unsetAll(); - page = p.next; + while (page) |p| : (page = p.next) { + for (p.data.rows.ptr(p.data.memory)[0..p.data.size.rows]) |*row| { + row.dirty = false; + } } } @@ -3965,13 +3966,12 @@ pub const Pin = struct { /// Check if this pin is dirty. pub inline fn isDirty(self: Pin) bool { - return self.node.data.isRowDirty(self.y); + return self.rowAndCell().row.dirty; } /// Mark this pin location as dirty. pub inline fn markDirty(self: Pin) void { - var set = self.node.data.dirtyBitSet(); - set.set(self.y); + self.rowAndCell().row.dirty = true; } /// Returns true if the row of this pin should never have its background @@ -4375,7 +4375,7 @@ const Cell = struct { /// This is not very performant this is primarily used for assertions /// and testing. pub fn isDirty(self: Cell) bool { - return self.node.data.isRowDirty(self.row_idx); + return self.row.dirty; } /// Get the cell style. diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 2f35fc5ed..986f6c79c 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -786,9 +786,7 @@ pub fn cursorDownScroll(self: *Screen) !void { self.cursor.page_row, page.getCells(self.cursor.page_row), ); - - var dirty = page.dirtyBitSet(); - dirty.set(0); + self.cursorMarkDirty(); } else { // The call to `eraseRow` will move the tracked cursor pin up by one // row, but we don't actually want that, so we keep the old pin and @@ -880,7 +878,7 @@ pub fn cursorScrollAbove(self: *Screen) !void { // the cursor always changes page rows inside this function, and // when that happens it can mean the text in the old row needs to // be re-shaped because the cursor splits runs to break ligatures. - self.cursor.page_pin.markDirty(); + self.cursorMarkDirty(); // If the cursor is on the bottom of the screen, its faster to use // our specialized function for that case. @@ -926,8 +924,9 @@ pub fn cursorScrollAbove(self: *Screen) !void { fastmem.rotateOnceR(Row, rows[pin.y..page.size.rows]); // Mark all our rotated rows as dirty. - var dirty = page.dirtyBitSet(); - dirty.setRangeValue(.{ .start = pin.y, .end = page.size.rows }, true); + for (rows[pin.y..page.size.rows]) |*row| { + row.dirty = true; + } // Setup our cursor caches after the rotation so it points to the // correct data @@ -993,8 +992,9 @@ fn cursorScrollAboveRotate(self: *Screen) !void { ); // All rows we rotated are dirty - var dirty = cur_page.dirtyBitSet(); - dirty.setRangeValue(.{ .start = 0, .end = cur_page.size.rows }, true); + for (cur_rows[0..cur_page.size.rows]) |*row| { + row.dirty = true; + } } // Our current is our cursor page, we need to rotate down from @@ -1010,11 +1010,9 @@ fn cursorScrollAboveRotate(self: *Screen) !void { ); // Set all the rows we rotated and cleared dirty - var dirty = cur_page.dirtyBitSet(); - dirty.setRangeValue( - .{ .start = self.cursor.page_pin.y, .end = cur_page.size.rows }, - true, - ); + for (cur_rows[self.cursor.page_pin.y..cur_page.size.rows]) |*row| { + row.dirty = true; + } // Setup cursor cache data after all the rotations so our // row is valid. @@ -1105,7 +1103,7 @@ inline fn cursorChangePin(self: *Screen, new: Pin) void { // we must mark the old and new page dirty. We do this as long // as the pins are not equal if (!self.cursor.page_pin.eql(new)) { - self.cursor.page_pin.markDirty(); + self.cursorMarkDirty(); new.markDirty(); } @@ -1175,7 +1173,7 @@ inline fn cursorChangePin(self: *Screen, new: Pin) void { /// Mark the cursor position as dirty. /// TODO: test pub inline fn cursorMarkDirty(self: *Screen) void { - self.cursor.page_pin.markDirty(); + self.cursor.page_row.dirty = true; } /// Reset the cursor row's soft-wrap state and the cursor's pending wrap. @@ -1303,10 +1301,6 @@ pub fn clearRows( var it = self.pages.pageIterator(.right_down, tl, bl); while (it.next()) |chunk| { - // Mark everything in this chunk as dirty - var dirty = chunk.node.data.dirtyBitSet(); - dirty.setRangeValue(.{ .start = chunk.start, .end = chunk.end }, true); - for (chunk.rows()) |*row| { const cells_offset = row.cells; const cells_multi: [*]Cell = row.cells.ptr(chunk.node.data.memory); @@ -1322,6 +1316,8 @@ pub fn clearRows( self.clearCells(&chunk.node.data, row, cells); row.* = .{ .cells = cells_offset }; } + + row.dirty = true; } } } diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index fb5b67127..664753b0b 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1672,6 +1672,9 @@ pub fn insertLines(self: *Terminal, count: usize) void { dst_row.* = src_row.*; src_row.* = dst; + // Make sure the row is marked as dirty though. + dst_row.dirty = true; + // Ensure what we did didn't corrupt the page cur_p.node.data.assertIntegrity(); } else { @@ -1867,6 +1870,9 @@ pub fn deleteLines(self: *Terminal, count: usize) void { dst_row.* = src_row.*; src_row.* = dst; + // Make sure the row is marked as dirty though. + dst_row.dirty = true; + // Ensure what we did didn't corrupt the page cur_p.node.data.assertIntegrity(); } else { diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 6ed1db51a..98de5ff17 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -136,44 +136,6 @@ pub const Page = struct { hyperlink_map: hyperlink.Map, hyperlink_set: hyperlink.Set, - /// The offset to the first mask of dirty bits in the page. - /// - /// The dirty bits is a contiguous array of usize where each bit represents - /// a row in the page, in order. If the bit is set, then the row is dirty - /// and requires a redraw. Dirty status is only ever meant to convey that - /// a cell has changed visually. A cell which changes in a way that doesn't - /// affect the visual representation may not be marked as dirty. - /// - /// Dirty tracking may have false positives but should never have false - /// negatives. A false negative would result in a visual artifact on the - /// screen. - /// - /// Dirty bits are only ever unset by consumers of a page. The page - /// structure itself does not unset dirty bits since the page does not - /// know when a cell has been redrawn. - /// - /// As implementation background: it may seem that dirty bits should be - /// stored elsewhere and not on the page itself, because the only data - /// that could possibly change is in the active area of a terminal - /// historically and that area is small compared to the typical scrollback. - /// My original thinking was to put the dirty bits on Screen instead and - /// have them only track the active area. However, I decided to put them - /// into the page directly for a few reasons: - /// - /// 1. It's simpler. The page is a self-contained unit and it's nice - /// to have all the data for a page in one place. - /// - /// 2. It's cheap. Even a very large page might have 1000 rows and - /// that's only ~128 bytes of 64-bit integers to track all the dirty - /// bits. Compared to the hundreds of kilobytes a typical page - /// consumes, this is nothing. - /// - /// 3. It's more flexible. If we ever want to implement new terminal - /// features that allow non-active area to be dirty, we can do that - /// with minimal dirty-tracking work. - /// - dirty: Offset(usize), - /// The current dimensions of the page. The capacity may be larger /// than this. This allows us to allocate a larger page than necessary /// and also to resize a page smaller without reallocating. @@ -238,7 +200,6 @@ pub const Page = struct { .memory = @alignCast(buf.start()[0..l.total_size]), .rows = rows, .cells = cells, - .dirty = buf.member(usize, l.dirty_start), .styles = StyleSet.init( buf.add(l.styles_start), l.styles_layout, @@ -686,11 +647,8 @@ pub const Page = struct { const other_rows = other.rows.ptr(other.memory)[y_start..y_end]; const rows = self.rows.ptr(self.memory)[0 .. y_end - y_start]; - const other_dirty_set = other.dirtyBitSet(); - var dirty_set = self.dirtyBitSet(); - for (rows, 0.., other_rows, y_start..) |*dst_row, dst_y, *src_row, src_y| { + for (rows, other_rows) |*dst_row, *src_row| { try self.cloneRowFrom(other, dst_row, src_row); - if (other_dirty_set.isSet(src_y)) dirty_set.set(dst_y); } // We should remain consistent @@ -752,6 +710,7 @@ pub const Page = struct { copy.grapheme = dst_row.grapheme; copy.hyperlink = dst_row.hyperlink; copy.styled = dst_row.styled; + copy.dirty |= dst_row.dirty; } // Our cell offset remains the same @@ -1501,30 +1460,12 @@ pub const Page = struct { return self.grapheme_map.map(self.memory).capacity(); } - /// Returns the bitset for the dirty bits on this page. - /// - /// The returned value is a DynamicBitSetUnmanaged but it is NOT - /// actually dynamic; do NOT call resize on this. It is safe to - /// read and write but do not resize it. - pub inline fn dirtyBitSet(self: *const Page) std.DynamicBitSetUnmanaged { - return .{ - .bit_length = self.capacity.rows, - .masks = self.dirty.ptr(self.memory), - }; - } - - /// Returns true if the given row is dirty. This is NOT very - /// efficient if you're checking many rows and you should use - /// dirtyBitSet directly instead. - pub inline fn isRowDirty(self: *const Page, y: usize) bool { - return self.dirtyBitSet().isSet(y); - } - - /// Returns true if this page is dirty at all. If you plan on - /// checking any additional rows, you should use dirtyBitSet and - /// check this on your own so you have the set available. + /// Returns true if this page is dirty at all. pub inline fn isDirty(self: *const Page) bool { - return self.dirtyBitSet().findFirstSet() != null; + for (self.rows.ptr(self.memory)[0..self.size.rows]) |row| { + if (row.dirty) return true; + } + return false; } pub const Layout = struct { @@ -1533,8 +1474,6 @@ pub const Page = struct { rows_size: usize, cells_start: usize, cells_size: usize, - dirty_start: usize, - dirty_size: usize, styles_start: usize, styles_layout: StyleSet.Layout, grapheme_alloc_start: usize, @@ -1561,19 +1500,8 @@ pub const Page = struct { const cells_start = alignForward(usize, rows_end, @alignOf(Cell)); const cells_end = cells_start + (cells_count * @sizeOf(Cell)); - // The division below cannot fail because our row count cannot - // exceed the maximum value of usize. - const dirty_bit_length: usize = rows_count; - const dirty_usize_length: usize = std.math.divCeil( - usize, - dirty_bit_length, - @bitSizeOf(usize), - ) catch unreachable; - const dirty_start = alignForward(usize, cells_end, @alignOf(usize)); - const dirty_end: usize = dirty_start + (dirty_usize_length * @sizeOf(usize)); - const styles_layout: StyleSet.Layout = .init(cap.styles); - const styles_start = alignForward(usize, dirty_end, StyleSet.base_align.toByteUnits()); + const styles_start = alignForward(usize, cells_end, StyleSet.base_align.toByteUnits()); const styles_end = styles_start + styles_layout.total_size; const grapheme_alloc_layout = GraphemeAlloc.layout(cap.grapheme_bytes); @@ -1614,8 +1542,6 @@ pub const Page = struct { .rows_size = rows_end - rows_start, .cells_start = cells_start, .cells_size = cells_end - cells_start, - .dirty_start = dirty_start, - .dirty_size = dirty_end - dirty_start, .styles_start = styles_start, .styles_layout = styles_layout, .grapheme_alloc_start = grapheme_alloc_start, @@ -1707,11 +1633,9 @@ pub const Capacity = struct { // The size per row is: // - The row metadata itself // - The cells per row (n=cols) - // - 1 bit for dirty tracking const bits_per_row: usize = size: { var bits: usize = @bitSizeOf(Row); // Row metadata bits += @bitSizeOf(Cell) * @as(usize, @intCast(cols)); // Cells (n=cols) - bits += 1; // The dirty bit break :size bits; }; const available_bits: usize = styles_start * 8; @@ -1775,7 +1699,20 @@ pub const Row = packed struct(u64) { // everything throughout the same. kitty_virtual_placeholder: bool = false, - _padding: u23 = 0, + /// True if this row is dirty and requires a redraw. This is set to true + /// by any operation that modifies the row's contents or position, and + /// consumers of the page are expected to clear it when they redraw. + /// + /// Dirty status is only ever meant to convey that one or more cells in + /// the row have changed visually. A cell which changes in a way that + /// doesn't affect the visual representation may not be marked as dirty. + /// + /// Dirty tracking may have false positives but should never have false + /// negatives. A false negative would result in a visual artifact on the + /// screen. + dirty: bool = false, + + _padding: u22 = 0, /// Semantic prompt type. pub const SemanticPrompt = enum(u3) { @@ -2079,10 +2016,6 @@ test "Page init" { .styles = 32, }); defer page.deinit(); - - // Dirty set should be empty - const dirty = page.dirtyBitSet(); - try std.testing.expectEqual(@as(usize, 0), dirty.count()); } test "Page read and write cells" { From 81eda848cb8b4239e4957d03629e5c724b3bf584 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 18 Nov 2025 14:38:07 -0700 Subject: [PATCH 029/209] perf: add full-page dirty flag Avoids overhead of marking many rows dirty in functions that manipulate row positions which dirties all or most of the page. --- src/renderer/generic.zig | 1 + src/terminal/PageList.zig | 60 +++++++++++++++++++-------------------- src/terminal/Screen.zig | 24 ++++++++-------- src/terminal/page.zig | 11 +++++++ 4 files changed, 53 insertions(+), 43 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 9fafc5a48..fc6cdf192 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1195,6 +1195,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { null, ); while (it.next()) |chunk| { + chunk.node.data.dirty = false; for (chunk.rows()) |*row| { row.dirty = false; } diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 5217e30bd..aab01fa7c 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -658,6 +658,8 @@ pub fn clone( chunk.end, ); + node.data.dirty = chunk.node.data.dirty; + page_list.append(node); total_rows += node.data.size.rows; @@ -2683,10 +2685,11 @@ pub fn eraseRow( // If we have a pinned viewport, we need to adjust for active area. self.fixupViewport(1); - // Set all the rows as dirty in this page, starting at the erased row. - for (rows[pn.y..node.data.size.rows]) |*row| { - row.dirty = true; - } + // Mark the whole page as dirty. + // + // Technically we only need to mark rows from the erased row to the end + // of the page as dirty, but that's slower and this is a hot function. + node.data.dirty = true; // We iterate through all of the following pages in order to move their // rows up by 1 as well. @@ -2719,10 +2722,8 @@ pub fn eraseRow( fastmem.rotateOnce(Row, rows[0..node.data.size.rows]); - // Set all the rows as dirty - for (rows[0..node.data.size.rows]) |*row| { - row.dirty = true; - } + // Mark the whole page as dirty. + node.data.dirty = true; // Our tracked pins for this page need to be updated. // If the pin is in row 0 that means the corresponding row has @@ -2773,10 +2774,11 @@ pub fn eraseRowBounded( node.data.clearCells(&rows[pn.y], 0, node.data.size.cols); fastmem.rotateOnce(Row, rows[pn.y..][0 .. limit + 1]); - // Set all the rows as dirty - for (rows[pn.y..][0..limit]) |*row| { - row.dirty = true; - } + // Mark the whole page as dirty. + // + // Technically we only need to mark from the erased row to the + // limit but this is a hot function, so we want to minimize work. + node.data.dirty = true; // If our viewport is a pin and our pin is within the erased // region we need to maybe shift our cache up. We do this here instead @@ -2813,10 +2815,11 @@ pub fn eraseRowBounded( fastmem.rotateOnce(Row, rows[pn.y..node.data.size.rows]); - // All the rows in the page are dirty below the erased row. - for (rows[pn.y..node.data.size.rows]) |*row| { - row.dirty = true; - } + // Mark the whole page as dirty. + // + // Technically we only need to mark rows from the erased row to the end + // of the page as dirty, but that's slower and this is a hot function. + node.data.dirty = true; // We need to keep track of how many rows we've shifted so that we can // determine at what point we need to do a partial shift on subsequent @@ -2871,10 +2874,11 @@ pub fn eraseRowBounded( node.data.clearCells(&rows[0], 0, node.data.size.cols); fastmem.rotateOnce(Row, rows[0 .. shifted_limit + 1]); - // Set all the rows as dirty - for (rows[0..shifted_limit]) |*row| { - row.dirty = true; - } + // Mark the whole page as dirty. + // + // Technically we only need to mark from the erased row to the + // limit but this is a hot function, so we want to minimize work. + node.data.dirty = true; // See the other places we do something similar in this function // for a detailed explanation. @@ -2904,10 +2908,8 @@ pub fn eraseRowBounded( fastmem.rotateOnce(Row, rows[0..node.data.size.rows]); - // Set all the rows as dirty - for (rows[0..node.data.size.rows]) |*row| { - row.dirty = true; - } + // Mark the whole page as dirty. + node.data.dirty = true; // Account for the rows shifted in this node. shifted += node.data.size.rows; @@ -3883,6 +3885,7 @@ fn growRows(self: *PageList, n: usize) !void { pub fn clearDirty(self: *PageList) void { var page = self.pages.first; while (page) |p| : (page = p.next) { + p.data.dirty = false; for (p.data.rows.ptr(p.data.memory)[0..p.data.size.rows]) |*row| { row.dirty = false; } @@ -3966,7 +3969,7 @@ pub const Pin = struct { /// Check if this pin is dirty. pub inline fn isDirty(self: Pin) bool { - return self.rowAndCell().row.dirty; + return self.node.data.dirty or self.rowAndCell().row.dirty; } /// Mark this pin location as dirty. @@ -4375,7 +4378,7 @@ const Cell = struct { /// This is not very performant this is primarily used for assertions /// and testing. pub fn isDirty(self: Cell) bool { - return self.row.dirty; + return self.node.data.dirty or self.row.dirty; } /// Get the cell style. @@ -6802,11 +6805,9 @@ test "PageList eraseRowBounded less than full row" { try testing.expectEqual(s.rows, s.totalRows()); // The erased rows should be dirty - try testing.expect(!s.isDirty(.{ .active = .{ .x = 0, .y = 4 } })); try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 5 } })); try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 6 } })); try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 7 } })); - try testing.expect(!s.isDirty(.{ .active = .{ .x = 0, .y = 8 } })); try testing.expectEqual(s.pages.first.?, p_top.node); try testing.expectEqual(@as(usize, 4), p_top.y); @@ -6840,7 +6841,6 @@ test "PageList eraseRowBounded with pin at top" { try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); - try testing.expect(!s.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); try testing.expectEqual(s.pages.first.?, p_top.node); try testing.expectEqual(@as(usize, 0), p_top.y); @@ -6865,7 +6865,6 @@ test "PageList eraseRowBounded full rows single page" { try testing.expectEqual(s.rows, s.totalRows()); // The erased rows should be dirty - try testing.expect(!s.isDirty(.{ .active = .{ .x = 0, .y = 4 } })); for (5..10) |y| try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = @intCast(y), @@ -6931,7 +6930,6 @@ test "PageList eraseRowBounded full rows two pages" { try s.eraseRowBounded(.{ .active = .{ .y = 4 } }, 4); // The erased rows should be dirty - try testing.expect(!s.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); for (4..8) |y| try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = @intCast(y), diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 986f6c79c..ec9056a01 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -923,10 +923,11 @@ pub fn cursorScrollAbove(self: *Screen) !void { var rows = page.rows.ptr(page.memory.ptr); fastmem.rotateOnceR(Row, rows[pin.y..page.size.rows]); - // Mark all our rotated rows as dirty. - for (rows[pin.y..page.size.rows]) |*row| { - row.dirty = true; - } + // Mark the whole page as dirty. + // + // Technically we only need to mark from the cursor row to the + // end but this is a hot function, so we want to minimize work. + page.dirty = true; // Setup our cursor caches after the rotation so it points to the // correct data @@ -991,10 +992,8 @@ fn cursorScrollAboveRotate(self: *Screen) !void { &prev_rows[prev_page.size.rows - 1], ); - // All rows we rotated are dirty - for (cur_rows[0..cur_page.size.rows]) |*row| { - row.dirty = true; - } + // Mark dirty on the page, since we are dirtying all rows with this. + cur_page.dirty = true; } // Our current is our cursor page, we need to rotate down from @@ -1009,10 +1008,11 @@ fn cursorScrollAboveRotate(self: *Screen) !void { cur_page.getCells(&cur_rows[self.cursor.page_pin.y]), ); - // Set all the rows we rotated and cleared dirty - for (cur_rows[self.cursor.page_pin.y..cur_page.size.rows]) |*row| { - row.dirty = true; - } + // Mark the whole page as dirty. + // + // Technically we only need to mark from the cursor row to the + // end but this is a hot function, so we want to minimize work. + cur_page.dirty = true; // Setup cursor cache data after all the rotations so our // row is valid. diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 98de5ff17..2541b2dd5 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -108,6 +108,15 @@ pub const Page = struct { /// first column, all cells in that row are laid out in column order. cells: Offset(Cell), + /// Set to true when an operation is performed that dirties all rows in + /// the page. See `Row.dirty` for more information on dirty tracking. + /// + /// NOTE: A value of false does NOT indicate that + /// the page has no dirty rows in it, only + /// that no full-page-dirtying operations + /// have occurred since it was last cleared. + dirty: bool, + /// The string allocator for this page used for shared utf-8 encoded /// strings. Liveness of strings and memory management is deferred to /// the individual use case. @@ -228,6 +237,7 @@ pub const Page = struct { ), .size = .{ .cols = cap.cols, .rows = cap.rows }, .capacity = cap, + .dirty = false, }; } @@ -1462,6 +1472,7 @@ pub const Page = struct { /// Returns true if this page is dirty at all. pub inline fn isDirty(self: *const Page) bool { + if (self.dirty) return true; for (self.rows.ptr(self.memory)[0..self.size.rows]) |row| { if (row.dirty) return true; } From d14b4cf0684fe40987f24df1849fee618423150e Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 18 Nov 2025 15:42:29 -0700 Subject: [PATCH 030/209] perf: streamline RefCountedSet lookup + add branch hint to insert --- src/terminal/ref_counted_set.zig | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig index 25512bdaf..3d0dd469a 100644 --- a/src/terminal/ref_counted_set.zig +++ b/src/terminal/ref_counted_set.zig @@ -515,14 +515,11 @@ pub fn RefCountedSet( return null; } - // We don't bother checking dead items. - if (item.meta.ref == 0) { - continue; - } - // If the item is a part of the same probe sequence, - // we check if it matches the value we're looking for. + // we make sure it's not dead and then check to see + // if it matches the value we're looking for. if (item.meta.psl == i and + item.meta.ref > 0 and ctx.eql(value, item.value)) { return id; @@ -594,6 +591,11 @@ pub fn RefCountedSet( // unless its ID is greater than the one we're // given (i.e. prefer smaller IDs). if (item.meta.ref == 0) { + // Dead items aren't super common relative + // to other places to insert/swap the held + // item in to the set. + @branchHint(.unlikely); + if (comptime @hasDecl(Context, "deleted")) { // Inform the context struct that we're // deleting the dead item's value for good. From 5ffa7f8f45fe3d0aed56a2ec299408546ee59b85 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 18 Nov 2025 16:13:15 -0700 Subject: [PATCH 031/209] perf: inline calls to StaticBitSet.isSet in sgr parser --- src/terminal/sgr.zig | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index dc9505d14..6fd4f1e79 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -197,7 +197,12 @@ pub const Parser = struct { } const slice = self.params[self.idx..self.params.len]; - const colon = self.params_sep.isSet(self.idx); + // Call inlined for performance reasons. + const colon = @call( + .always_inline, + SepList.isSet, + .{ self.params_sep, self.idx }, + ); self.idx += 1; // Our last one will have an idx be the last value. @@ -485,10 +490,13 @@ pub const Parser = struct { /// Returns true if the present position has a colon separator. /// This always returns false for the last value since it has no /// separator. - fn isColon(self: *Parser) bool { - // The `- 1` here is because the last value has no separator. - if (self.idx >= self.params.len - 1) return false; - return self.params_sep.isSet(self.idx); + inline fn isColon(self: *Parser) bool { + // Call inlined for performance reasons. + return @call( + .always_inline, + SepList.isSet, + .{ self.params_sep, self.idx }, + ); } fn countColon(self: *Parser) usize { @@ -514,7 +522,9 @@ fn testParse(params: []const u16) Attribute { } fn testParseColon(params: []const u16) Attribute { - var p: Parser = .{ .params = params, .params_sep = .initFull() }; + var p: Parser = .{ .params = params }; + // Mark all parameters except the last as having a colon after. + for (0..params.len - 1) |i| p.params_sep.set(i); return p.next().?; } From f9e245ab7fec34e908cf541988099e05d236bd42 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 18 Nov 2025 17:43:04 -0700 Subject: [PATCH 032/209] perf: separate clearing graphemes/hyperlinks from updating row flag This improves the `clearCells` function since it only has to update once after clearing all of the individual cells, or not at all if the whole row was cleared since then it knows for sure that it cleared them all. This also makes it so that the row style flag is properly tracked when cells are cleared but not the whole row. --- src/terminal/Screen.zig | 49 +++++++++++++++------ src/terminal/Terminal.zig | 16 ++++--- src/terminal/page.zig | 91 ++++++++++++++++++++++++++++++++------- 3 files changed, 121 insertions(+), 35 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index ec9056a01..491d576ea 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1322,8 +1322,9 @@ pub fn clearRows( } } -/// Clear the cells with the blank cell. This takes care to handle -/// cleaning up graphemes and styles. +/// Clear the cells with the blank cell. +/// +/// This takes care to handle cleaning up graphemes and styles. pub fn clearCells( self: *Screen, page: *Page, @@ -1350,30 +1351,54 @@ pub fn clearCells( assert(@intFromPtr(&cells[cells.len - 1]) <= @intFromPtr(&row_cells[row_cells.len - 1])); } - // If this row has graphemes, then we need go through a slow path - // and delete the cell graphemes. + // If we have managed memory (styles, graphemes, or hyperlinks) + // in this row then we go cell by cell and clear them if present. if (row.grapheme) { for (cells) |*cell| { - if (cell.hasGrapheme()) page.clearGrapheme(row, cell); + if (cell.hasGrapheme()) + page.clearGrapheme(cell); + } + + // If we have no left/right scroll region we can be sure + // that we've cleared all the graphemes, so we clear the + // flag, otherwise we ask the page to update the flag. + if (cells.len == self.pages.cols) { + row.grapheme = false; + } else { + page.updateRowGraphemeFlag(row); } } - // If we have hyperlinks, we need to clear those. if (row.hyperlink) { for (cells) |*cell| { - if (cell.hyperlink) page.clearHyperlink(row, cell); + if (cell.hyperlink) + page.clearHyperlink(cell); + } + + // If we have no left/right scroll region we can be sure + // that we've cleared all the hyperlinks, so we clear the + // flag, otherwise we ask the page to update the flag. + if (cells.len == self.pages.cols) { + row.hyperlink = false; + } else { + page.updateRowHyperlinkFlag(row); } } if (row.styled) { for (cells) |*cell| { - if (cell.style_id == style.default_id) continue; - page.styles.release(page.memory, cell.style_id); + if (cell.hasStyling()) + page.styles.release(page.memory, cell.style_id); } - // If we have no left/right scroll region we can be sure that - // the row is no longer styled. - if (cells.len == self.pages.cols) row.styled = false; + // If we have no left/right scroll region we can be sure + // that we've cleared all the styles, so we clear the + // flag, otherwise we ask the page to update the flag. + if (cells.len == self.pages.cols) { + row.styled = false; + } else { + page.updateRowStyledFlag(row); + } } if (comptime build_options.kitty_graphics) { diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 664753b0b..e02b58e57 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -683,10 +683,9 @@ fn printCell( // If the prior value had graphemes, clear those if (cell.hasGrapheme()) { - self.screens.active.cursor.page_pin.node.data.clearGrapheme( - self.screens.active.cursor.page_row, - cell, - ); + const page = &self.screens.active.cursor.page_pin.node.data; + page.clearGrapheme(cell); + page.updateRowGraphemeFlag(self.screens.active.cursor.page_row); } // We don't need to update the style refs unless the @@ -745,7 +744,8 @@ fn printCell( } else if (had_hyperlink) { // If the previous cell had a hyperlink then we need to clear it. var page = &self.screens.active.cursor.page_pin.node.data; - page.clearHyperlink(self.screens.active.cursor.page_row, cell); + page.clearHyperlink(cell); + page.updateRowHyperlinkFlag(self.screens.active.cursor.page_row); } } @@ -1474,7 +1474,8 @@ fn rowWillBeShifted( if (left_cell.wide == .spacer_tail) { const wide_cell: *Cell = &cells[self.scrolling_region.left - 1]; if (wide_cell.hasGrapheme()) { - page.clearGrapheme(row, wide_cell); + page.clearGrapheme(wide_cell); + page.updateRowGraphemeFlag(row); } wide_cell.content.codepoint = 0; wide_cell.wide = .narrow; @@ -1484,7 +1485,8 @@ fn rowWillBeShifted( if (right_cell.wide == .wide) { const tail_cell: *Cell = &cells[self.scrolling_region.right + 1]; if (right_cell.hasGrapheme()) { - page.clearGrapheme(row, right_cell); + page.clearGrapheme(right_cell); + page.updateRowGraphemeFlag(row); } right_cell.content.codepoint = 0; right_cell.wide = .narrow; diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 2541b2dd5..8fc704310 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1059,26 +1059,54 @@ pub const Page = struct { const cells = row.cells.ptr(self.memory)[left..end]; + // If we have managed memory (styles, graphemes, or hyperlinks) + // in this row then we go cell by cell and clear them if present. if (row.grapheme) { for (cells) |*cell| { - if (cell.hasGrapheme()) self.clearGrapheme(row, cell); + if (cell.hasGrapheme()) + self.clearGrapheme(cell); + } + + // If we have no left/right scroll region we can be sure + // that we've cleared all the graphemes, so we clear the + // flag, otherwise we use the update function to update. + if (cells.len == self.size.cols) { + row.grapheme = false; + } else { + self.updateRowGraphemeFlag(row); } } if (row.hyperlink) { for (cells) |*cell| { - if (cell.hyperlink) self.clearHyperlink(row, cell); + if (cell.hyperlink) + self.clearHyperlink(cell); + } + + // If we have no left/right scroll region we can be sure + // that we've cleared all the hyperlinks, so we clear the + // flag, otherwise we use the update function to update. + if (cells.len == self.size.cols) { + row.hyperlink = false; + } else { + self.updateRowHyperlinkFlag(row); } } if (row.styled) { for (cells) |*cell| { - if (cell.style_id == stylepkg.default_id) continue; - - self.styles.release(self.memory, cell.style_id); + if (cell.hasStyling()) + self.styles.release(self.memory, cell.style_id); } - if (cells.len == self.size.cols) row.styled = false; + // If we have no left/right scroll region we can be sure + // that we've cleared all the styles, so we clear the + // flag, otherwise we use the update function to update. + if (cells.len == self.size.cols) { + row.styled = false; + } else { + self.updateRowStyledFlag(row); + } } if (comptime build_options.kitty_graphics) { @@ -1106,7 +1134,11 @@ pub const Page = struct { } /// Clear the hyperlink from the given cell. - pub inline fn clearHyperlink(self: *Page, row: *Row, cell: *Cell) void { + /// + /// In order to update the hyperlink flag on the row, call + /// `updateRowHyperlinkFlag` after you finish clearing any + /// hyperlinks in the row. + pub inline fn clearHyperlink(self: *Page, cell: *Cell) void { defer self.assertIntegrity(); // Get our ID @@ -1118,9 +1150,13 @@ pub const Page = struct { self.hyperlink_set.release(self.memory, entry.value_ptr.*); map.removeByPtr(entry.key_ptr); cell.hyperlink = false; + } - // Mark that we no longer have hyperlinks, also search the row - // to make sure its state is correct. + /// Checks if the row contains any hyperlinks and sets + /// the hyperlink flag to false if none are found. + /// + /// Call after removing hyperlinks in a row. + pub inline fn updateRowHyperlinkFlag(self: *Page, row: *Row) void { const cells = row.cells.ptr(self.memory)[0..self.size.cols]; for (cells) |c| if (c.hyperlink) return; row.hyperlink = false; @@ -1434,7 +1470,11 @@ pub const Page = struct { } /// Clear the graphemes for a given cell. - pub inline fn clearGrapheme(self: *Page, row: *Row, cell: *Cell) void { + /// + /// In order to update the grapheme flag on the row, call + /// `updateRowGraphemeFlag` after you finish clearing any + /// graphemes in the row. + pub inline fn clearGrapheme(self: *Page, cell: *Cell) void { defer self.assertIntegrity(); if (build_options.slow_runtime_safety) assert(cell.hasGrapheme()); @@ -1450,9 +1490,15 @@ pub const Page = struct { // Remove the entry map.removeByPtr(entry.key_ptr); - // Mark that we no longer have graphemes, also search the row - // to make sure its state is correct. + // Mark that we no longer have graphemes by changing the content tag. cell.content_tag = .codepoint; + } + + /// Checks if the row contains any graphemes and sets + /// the grapheme flag to false if none are found. + /// + /// Call after removing graphemes in a row. + pub inline fn updateRowGraphemeFlag(self: *Page, row: *Row) void { const cells = row.cells.ptr(self.memory)[0..self.size.cols]; for (cells) |c| if (c.hasGrapheme()) return; row.grapheme = false; @@ -1470,6 +1516,16 @@ pub const Page = struct { return self.grapheme_map.map(self.memory).capacity(); } + /// Checks if the row contains any styles and sets + /// the styled flag to false if none are found. + /// + /// Call after removing styles in a row. + pub inline fn updateRowStyledFlag(self: *Page, row: *Row) void { + const cells = row.cells.ptr(self.memory)[0..self.size.cols]; + for (cells) |c| if (c.hasStyling()) return; + row.styled = false; + } + /// Returns true if this page is dirty at all. pub inline fn isDirty(self: *const Page) bool { if (self.dirty) return true; @@ -1750,7 +1806,7 @@ pub const Row = packed struct(u64) { /// Returns true if this row has any managed memory outside of the /// row structure (graphemes, styles, etc.) - fn managedMemory(self: Row) bool { + inline fn managedMemory(self: Row) bool { return self.grapheme or self.styled or self.hyperlink; } }; @@ -2076,7 +2132,8 @@ test "Page appendGrapheme small" { try testing.expectEqualSlices(u21, &.{ 0x0A, 0x0B }, page.lookupGrapheme(rac.cell).?); // Clear it - page.clearGrapheme(rac.row, rac.cell); + page.clearGrapheme(rac.cell); + page.updateRowGraphemeFlag(rac.row); try testing.expect(!rac.row.grapheme); try testing.expect(!rac.cell.hasGrapheme()); } @@ -2121,7 +2178,8 @@ test "Page clearGrapheme not all cells" { try page.appendGrapheme(rac2.row, rac2.cell, 0x0A); // Clear it - page.clearGrapheme(rac.row, rac.cell); + page.clearGrapheme(rac.cell); + page.updateRowGraphemeFlag(rac.row); try testing.expect(rac.row.grapheme); try testing.expect(!rac.cell.hasGrapheme()); try testing.expect(rac2.cell.hasGrapheme()); @@ -2385,7 +2443,8 @@ test "Page cloneFrom graphemes" { // Write again for (0..page.capacity.rows) |y| { const rac = page.getRowAndCell(1, y); - page.clearGrapheme(rac.row, rac.cell); + page.clearGrapheme(rac.cell); + page.updateRowGraphemeFlag(rac.row); rac.cell.* = .{ .content_tag = .codepoint, .content = .{ .codepoint = 0 }, From 0ce3d0bd078e6a6a52fb8edbbeba2fdef54a4658 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 18 Nov 2025 18:08:52 -0700 Subject: [PATCH 033/209] remove useless code the style ID is reset up above --- src/terminal/page.zig | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 8fc704310..3ccce452e 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -760,12 +760,6 @@ pub const Page = struct { } if (src_cell.hasGrapheme()) { - // To prevent integrity checks flipping. This will - // get fixed up when we check the style id below. - if (build_options.slow_runtime_safety) { - dst_cell.style_id = stylepkg.default_id; - } - // Copy the grapheme codepoints const cps = other.lookupGrapheme(src_cell).?; From 8d8798bc79602d48473299aeb871b4615f5be611 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 19 Nov 2025 06:15:26 -1000 Subject: [PATCH 034/209] renderer: minor log update, all commented --- src/renderer/generic.zig | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 912dcc457..aabec482b 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1084,8 +1084,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // const start_micro = std.time.microTimestamp(); // defer { // const end = std.time.Instant.now() catch unreachable; - // // "[updateFrame critical time] \t" - // std.log.err("[updateFrame critical time] {}\t{}", .{start_micro, end.since(start) / std.time.ns_per_us}); + // std.log.err("[updateFrame critical time] start={}\tduration={} us", .{ start_micro, end.since(start) / std.time.ns_per_us }); // } state.mutex.lock(); From 45b8ce842ec4345b6d80adb599dcb47c907dc6c9 Mon Sep 17 00:00:00 2001 From: Charles Nicholson Date: Fri, 31 Oct 2025 14:42:57 -0400 Subject: [PATCH 035/209] Cell width calculation from ceil to round, fix horizontal spacing --- src/font/Metrics.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/font/Metrics.zig b/src/font/Metrics.zig index ec89763ea..d4400a340 100644 --- a/src/font/Metrics.zig +++ b/src/font/Metrics.zig @@ -223,12 +223,12 @@ pub const FaceMetrics = struct { /// /// For any nullable options that are not provided, estimates will be used. pub fn calc(face: FaceMetrics) Metrics { - // We use the ceiling of the provided cell width and height to ensure - // that the cell is large enough for the provided size, since we cast - // it to an integer later. + // We use rounding for cell width to match the glyph advances from CoreText, + // which avoids spacing issues on non-Retina displays. + // We keep ceiling for cell height to ensure vertical space is sufficient. const face_width = face.cell_width; const face_height = face.lineHeight(); - const cell_width = @ceil(face_width); + const cell_width = @round(face_width); const cell_height = @ceil(face_height); // We split our line gap in two parts, and put half of it on the top From 801a399f41a1e65a60297e5e1b11ea6e706a15e4 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 19 Nov 2025 11:09:37 -0700 Subject: [PATCH 036/209] clarify comment --- src/font/Metrics.zig | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/font/Metrics.zig b/src/font/Metrics.zig index d4400a340..3bd8ed69c 100644 --- a/src/font/Metrics.zig +++ b/src/font/Metrics.zig @@ -223,11 +223,33 @@ pub const FaceMetrics = struct { /// /// For any nullable options that are not provided, estimates will be used. pub fn calc(face: FaceMetrics) Metrics { - // We use rounding for cell width to match the glyph advances from CoreText, - // which avoids spacing issues on non-Retina displays. - // We keep ceiling for cell height to ensure vertical space is sufficient. + // These are the unrounded advance width and line height values, + // which are retained separately from the rounded cell width and + // height values (below), for calculations that need to know how + // much error there is between the design dimensions of the font + // and the pixel dimensions of our cells. const face_width = face.cell_width; const face_height = face.lineHeight(); + + // The cell width and height values need to be integers since they + // represent pixel dimensions of the grid cells in the terminal. + // + // We use @round for the cell width to limit the difference from + // the "true" width value to no more than 0.5px. This is a better + // approximation of the authorial intent of the font than ceiling + // would be, and makes the apparent spacing match better between + // low and high DPI displays. + // + // This does mean that it's possible for a glyph to overflow the + // edge of the cell by a pixel if it has no side bearings, but in + // reality such glyphs are generally meant to connect to adjacent + // glyphs in some way so it's not really an issue. + // + // TODO: Reconsider cell height, should it also be rounded? + // We use @ceil because that's what we used initially, + // with the idea that it makes sure there's enough room + // for glyphs that use the entire line height, but it + // does create the same high/low DPI disparity issue... const cell_width = @round(face_width); const cell_height = @ceil(face_height); From 7d89aa764d17a5c216e6907866b407217e74e008 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 19 Nov 2025 12:37:09 -0700 Subject: [PATCH 037/209] perf: remove some overzealous inline annotations These were actually hurting performance lol, except in the places where I added the `.always_inline` calls- for some reason if these functions aren't inlined there it really messes up the top region scrolling benchmark in vtebench and I'm not entirely certain why... --- src/terminal/page.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 3ccce452e..25ddbe8d2 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1058,7 +1058,7 @@ pub const Page = struct { if (row.grapheme) { for (cells) |*cell| { if (cell.hasGrapheme()) - self.clearGrapheme(cell); + @call(.always_inline, clearGrapheme, .{ self, cell }); } // If we have no left/right scroll region we can be sure @@ -1074,7 +1074,7 @@ pub const Page = struct { if (row.hyperlink) { for (cells) |*cell| { if (cell.hyperlink) - self.clearHyperlink(cell); + @call(.always_inline, clearHyperlink, .{ self, cell }); } // If we have no left/right scroll region we can be sure @@ -1132,7 +1132,7 @@ pub const Page = struct { /// In order to update the hyperlink flag on the row, call /// `updateRowHyperlinkFlag` after you finish clearing any /// hyperlinks in the row. - pub inline fn clearHyperlink(self: *Page, cell: *Cell) void { + pub fn clearHyperlink(self: *Page, cell: *Cell) void { defer self.assertIntegrity(); // Get our ID @@ -1468,7 +1468,7 @@ pub const Page = struct { /// In order to update the grapheme flag on the row, call /// `updateRowGraphemeFlag` after you finish clearing any /// graphemes in the row. - pub inline fn clearGrapheme(self: *Page, cell: *Cell) void { + pub fn clearGrapheme(self: *Page, cell: *Cell) void { defer self.assertIntegrity(); if (build_options.slow_runtime_safety) assert(cell.hasGrapheme()); From e799023b89b0b7f9160e10629ba79efbf036e512 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 19 Nov 2025 13:32:17 -0700 Subject: [PATCH 038/209] perf: inline trivial charset lookup --- src/terminal/charsets.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal/charsets.zig b/src/terminal/charsets.zig index 05ebb40b6..00a2d8d1f 100644 --- a/src/terminal/charsets.zig +++ b/src/terminal/charsets.zig @@ -24,7 +24,7 @@ pub const Charset = LibEnum( /// The table for the given charset. This returns a pointer to a /// slice that is guaranteed to be 255 chars that can be used to map /// ASCII to the given charset. -pub fn table(set: Charset) []const u16 { +pub inline fn table(set: Charset) []const u16 { return switch (set) { .british => &british, .dec_special => &dec_special, From d2316ee718f59a62a585490f22cc4e99b77750dd Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 19 Nov 2025 14:58:47 -0700 Subject: [PATCH 039/209] perf: inline size.getOffset and intFromBase --- src/terminal/size.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/terminal/size.zig b/src/terminal/size.zig index 13ba636c3..0dedfcc14 100644 --- a/src/terminal/size.zig +++ b/src/terminal/size.zig @@ -123,7 +123,7 @@ pub const OffsetBuf = struct { /// Get the offset for a given type from some base pointer to the /// actual pointer to the type. -pub fn getOffset( +pub inline fn getOffset( comptime T: type, base: anytype, ptr: *const T, @@ -134,7 +134,7 @@ pub fn getOffset( return .{ .offset = @intCast(offset) }; } -fn intFromBase(base: anytype) usize { +inline fn intFromBase(base: anytype) usize { const T = @TypeOf(base); return switch (@typeInfo(T)) { .pointer => |v| switch (v.size) { From 42c1345238a7e50eed58ebfebf5d0746e6cd9beb Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Fri, 31 Oct 2025 20:16:28 -0700 Subject: [PATCH 040/209] CoreText: Apply subpixel halign also when cell width < advance --- src/font/face/coretext.zig | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 71bacb545..1d1333882 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -367,9 +367,16 @@ pub const Face = struct { // We don't do this if the glyph has a stretch constraint, // since in that case the position was already calculated with the // new cell width in mind. - if ((constraint.size != .stretch) and (metrics.face_width < cell_width)) { + if (constraint.size != .stretch) { // We add half the difference to re-center. - x += (cell_width - metrics.face_width) / 2; + const dx = (cell_width - metrics.face_width) / 2; + x += dx; + if (dx < 0) { + // For negative diff (cell narrower than advance), we remove the + // integer part and only keep the fractional adjustment needed + // for consistent subpixel positioning. + x -= @trunc(dx); + } } // If this is a bitmap glyph, it will always render as full pixels, From a3474061374fbe3284d9bd3dc05892176b516d56 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 19 Nov 2025 22:02:17 -0700 Subject: [PATCH 041/209] fix(font/CoreText): make system fallback fonts work again The code that re-creates the font descriptor from scratch using the same attributes also rubs off the magic dust that makes CoreText not throw a fit at us for using a "hidden" system font (name prefixed with a dot) by name when we use the descriptor. This means that a small subset of chars that only have glyphs in these fallback system fonts like ".CJK Symbols Fallback HK Regular" and ".DecoType Nastaleeq Urdu UI" would not be able to be rendered, since when we requested the font with the non-magical descriptor CoreText would complain in the console and give us Times New Roman instead. Using `CTFontDescriptorCreateCopyWithAttributes` to clear the charset attribute instead of recreating from scratch makes the copy come out magical, and CoreText lets us instantiate the font from it, yippee! --- src/font/discovery.zig | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/font/discovery.zig b/src/font/discovery.zig index 2f8412790..45fc89ea9 100644 --- a/src/font/discovery.zig +++ b/src/font/discovery.zig @@ -845,15 +845,20 @@ pub const CoreText = struct { // limitation because we may have used that to filter but we // don't want it anymore because it'll restrict the characters // available. - //const desc = self.list.getValueAtIndex(macos.text.FontDescriptor, self.i); const desc = desc: { - const original = self.list[self.i]; - - // For some reason simply copying the attributes and recreating - // the descriptor removes the charset restriction. This is tested. - const attrs = original.copyAttributes(); + // We create a copy, overwriting the character set attribute. + const attrs = try macos.foundation.MutableDictionary.create(0); defer attrs.release(); - break :desc try macos.text.FontDescriptor.createWithAttributes(@ptrCast(attrs)); + + attrs.setValue( + macos.text.FontAttribute.character_set.key(), + macos.c.kCFNull, + ); + + break :desc try macos.text.FontDescriptor.createCopyWithAttributes( + self.list[self.i], + @ptrCast(attrs), + ); }; defer desc.release(); From c93727697647066021bfe329ac0808d72049b826 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Thu, 20 Nov 2025 08:57:46 -0800 Subject: [PATCH 042/209] Clarify window-title-font-family availability --- src/config/Config.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 89e6a3e0f..6355b6c26 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1765,7 +1765,7 @@ keybind: Keybinds = .{}, /// Note: any font available on the system may be used, this font is not /// required to be a fixed-width font. /// -/// Available since: 1.1.0 (on GTK) +/// Available since: 1.0.0 on macOS, 1.1.0 on GTK @"window-title-font-family": ?[:0]const u8 = null, /// The text that will be displayed in the subtitle of the window. Valid values: From 1fd7606db64914e6e816ecf935a59652fccf7637 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 19 Nov 2025 20:22:22 -0700 Subject: [PATCH 043/209] font: round cell height from line height instead of ceiling This change should give more consistent results between high and low DPI displays, and generally approximate the authorial intent of the metrics a little better. Also changed the cell height adjustment to prioritize the top or bottom when adjusting by an odd number depending on whether the face is higher or lower in the cell than it "should" be. This should make it easier for users who have an issue with a glyph protruding from the cell to adjust the height and resolve it. --- src/font/Metrics.zig | 147 ++++++++++++++++++++++++++++++------------- 1 file changed, 105 insertions(+), 42 deletions(-) diff --git a/src/font/Metrics.zig b/src/font/Metrics.zig index 3bd8ed69c..a72cb7bee 100644 --- a/src/font/Metrics.zig +++ b/src/font/Metrics.zig @@ -1,6 +1,7 @@ const Metrics = @This(); const std = @import("std"); +const assert = @import("../quirks.zig").inlineAssert; /// Recommended cell width and height for a monospace grid using this font. cell_width: u32, @@ -47,8 +48,9 @@ face_width: f64, /// The unrounded face height, used in scaling calculations. face_height: f64, -/// The vertical bearing of face within the pixel-rounded -/// and possibly height-adjusted cell +/// The offset from the bottom of the cell to the bottom +/// of the face's bounding box, based on the rounded and +/// potentially adjusted cell height. face_y: f64, /// Minimum acceptable values for some fields to prevent modifiers @@ -245,25 +247,46 @@ pub fn calc(face: FaceMetrics) Metrics { // reality such glyphs are generally meant to connect to adjacent // glyphs in some way so it's not really an issue. // - // TODO: Reconsider cell height, should it also be rounded? - // We use @ceil because that's what we used initially, - // with the idea that it makes sure there's enough room - // for glyphs that use the entire line height, but it - // does create the same high/low DPI disparity issue... + // The same is true for the height. Some fonts are poorly authored + // and have a descender on a normal glyph that extends right up to + // the descent value of the face, and this can result in the glyph + // overflowing the bottom of the cell by a pixel, which isn't good + // but if we try to prevent it by increasing the cell height then + // we get line heights that are too large for most users and even + // more inconsistent across DPIs. + // + // Users who experience such cell-height overflows should: + // + // 1. Nag the font author to either redesign the glyph to not go + // so low, or else adjust the descent value in the metadata. + // + // 2. Add an `adjust-cell-height` entry to their config to give + // the cell enough room for the glyph. const cell_width = @round(face_width); - const cell_height = @ceil(face_height); + const cell_height = @round(face_height); // We split our line gap in two parts, and put half of it on the top // of the cell and the other half on the bottom, so that our text never // bumps up against either edge of the cell vertically. const half_line_gap = face.line_gap / 2; - // Unlike all our other metrics, `cell_baseline` is relative to the - // BOTTOM of the cell. + // NOTE: Unlike all our other metrics, `cell_baseline` is + // relative to the BOTTOM of the cell rather than the top. const face_baseline = half_line_gap - face.descent; - const cell_baseline = @round(face_baseline); + // We calculate the baseline by trying to center the face vertically + // in the pixel-rounded cell height, so that before rounding it will + // be an even distance from the top and bottom of the cell, meaning + // it either sticks out the same amount or is inset the same amount, + // depending on whether the cell height was rounded up or down from + // the line height. We do this by adding half the difference between + // the cell height and the face height. + const cell_baseline = @round(face_baseline - (cell_height - face_height) / 2); - // We keep track of the vertical bearing of the face in the cell + // We keep track of the offset from the bottom of the cell + // to the bottom of the face's "true" bounding box, which at + // this point, since nothing has been scaled yet, is equivalent + // to the offset between the baseline we draw at (cell_baseline) + // and the one the font wants (face_baseline). const face_y = cell_baseline - face_baseline; // We calculate a top_to_baseline to make following calculations simpler. @@ -333,29 +356,48 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void { // here is to center the baseline so that text is vertically // centered in the cell. if (comptime tag == .cell_height) { - // We split the difference in half because we want to - // center the baseline in the cell. If the difference - // is odd, one more pixel is added/removed on top than - // on the bottom. - if (new > original) { - const diff = new - original; - const diff_bottom = diff / 2; - const diff_top = diff - diff_bottom; - self.face_y += @floatFromInt(diff_bottom); - self.cell_baseline +|= diff_bottom; - self.underline_position +|= diff_top; - self.strikethrough_position +|= diff_top; - self.overline_position +|= @as(i32, @intCast(diff_top)); - } else { - const diff = original - new; - const diff_bottom = diff / 2; - const diff_top = diff - diff_bottom; - self.face_y -= @floatFromInt(diff_bottom); - self.cell_baseline -|= diff_bottom; - self.underline_position -|= diff_top; - self.strikethrough_position -|= diff_top; - self.overline_position -|= @as(i32, @intCast(diff_top)); - } + const original_f64: f64 = @floatFromInt(original); + const new_f64: f64 = @floatFromInt(new); + const diff = new_f64 - original_f64; + const half_diff = diff / 2.0; + + // If the diff is even, the number of pixels we add + // will be the same for the top and the bottom, but + // if the diff is odd then we want to add the extra + // pixel to the edge of the cell that needs it most. + // + // How much the edge "needs it" depends on whether + // the face is higher or lower than it should be to + // be perfectly centered in the cell. + // + // If the face were perfectly centered then face_y + // would be equal to half of the difference between + // the cell height and the face height. + const position_with_respect_to_center = + self.face_y - (original_f64 - self.face_height) / 2; + + const diff_top, const diff_bottom = + if (position_with_respect_to_center > 0) + // The baseline is higher than it should be, so we + // add the extra to the top, or if it's a negative + // diff it gets added to the bottom because of how + // floor and ceil work. + .{ @ceil(half_diff), @floor(half_diff) } + else + // The baseline is lower than it should be, so we + // add the extra to the bottom, or vice versa for + // negative diffs. + .{ @floor(half_diff), @ceil(half_diff) }; + + // The cell baseline and face_y values are relative to the + // bottom of the cell so we add the bottom diff to them. + addFloatToInt(&self.cell_baseline, diff_bottom); + self.face_y += diff_bottom; + + // These are all relative to the top of the cell. + addFloatToInt(&self.underline_position, diff_top); + addFloatToInt(&self.strikethrough_position, diff_top); + self.overline_position +|= @as(i32, @intFromFloat(diff_top)); } }, inline .icon_height => { @@ -373,6 +415,21 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void { self.clamp(); } +/// Helper function for adding an f64 to a u32. +/// +/// Performs saturating addition or subtraction +/// depending on the sign of the provided float. +/// +/// The f64 is asserted to have an integer value. +inline fn addFloatToInt(int: *u32, float: f64) void { + assert(@floor(float) == float); + int.* = + if (float >= 0.0) + int.* +| @as(u32, @intFromFloat(float)) + else + int.* -| @as(u32, @intFromFloat(-float)); +} + /// Clamp all metrics to their allowable range. fn clamp(self: *Metrics) void { inline for (std.meta.fields(Metrics)) |field| { @@ -592,7 +649,9 @@ test "Metrics: adjust cell height smaller" { defer set.deinit(alloc); // We choose numbers such that the subtracted number of pixels is odd, // as that's the case that could most easily have off-by-one errors. - // Here we're removing 25 pixels: 12 on the bottom, 13 on top. + // Here we're removing 25 pixels: 13 on the bottom, 12 on top, split + // that way because we're simulating a face that's 0.33px higher than + // it "should" be (due to rounding). try set.put(alloc, .cell_height, .{ .percent = 0.75 }); var m: Metrics = init(); @@ -602,14 +661,15 @@ test "Metrics: adjust cell height smaller" { m.strikethrough_position = 30; m.overline_position = 0; m.cell_height = 100; + m.face_height = 99.67; m.cursor_height = 100; m.apply(set); - try testing.expectEqual(-11.67, m.face_y); + try testing.expectEqual(-12.67, m.face_y); try testing.expectEqual(@as(u32, 75), m.cell_height); - try testing.expectEqual(@as(u32, 38), m.cell_baseline); - try testing.expectEqual(@as(u32, 42), m.underline_position); - try testing.expectEqual(@as(u32, 17), m.strikethrough_position); - try testing.expectEqual(@as(i32, -13), m.overline_position); + try testing.expectEqual(@as(u32, 37), m.cell_baseline); + try testing.expectEqual(@as(u32, 43), m.underline_position); + try testing.expectEqual(@as(u32, 18), m.strikethrough_position); + try testing.expectEqual(@as(i32, -12), m.overline_position); // Cursor height is separate from cell height and does not follow it. try testing.expectEqual(@as(u32, 100), m.cursor_height); } @@ -622,7 +682,9 @@ test "Metrics: adjust cell height larger" { defer set.deinit(alloc); // We choose numbers such that the added number of pixels is odd, // as that's the case that could most easily have off-by-one errors. - // Here we're adding 75 pixels: 37 on the bottom, 38 on top. + // Here we're adding 75 pixels: 37 on the bottom, 38 on top, split + // that way because we're simulating a face that's 0.33px higher + // than it "should" be (due to rounding). try set.put(alloc, .cell_height, .{ .percent = 1.75 }); var m: Metrics = init(); @@ -632,6 +694,7 @@ test "Metrics: adjust cell height larger" { m.strikethrough_position = 30; m.overline_position = 0; m.cell_height = 100; + m.face_height = 99.67; m.cursor_height = 100; m.apply(set); try testing.expectEqual(37.33, m.face_y); From 81a6c241865be2bf0579a9b6783fe706492ee9e4 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 20 Nov 2025 12:44:01 -0700 Subject: [PATCH 044/209] font/sprite: rework undercurl, fix out of bounds underlines Use z2d to draw the undercurl instead of the manual raster code we had before- the code was cool but unnecessarily complicated. Plus z2d lets us have rounded caps on the undercurl which is neat. Also make sure we won't draw off the canvas with our underlines-- the canvas has padding but it's not infinite. --- src/font/sprite/Face.zig | 1 + src/font/sprite/draw/special.zig | 236 +++++++++++++++++-------------- 2 files changed, 128 insertions(+), 109 deletions(-) diff --git a/src/font/sprite/Face.zig b/src/font/sprite/Face.zig index 29a7da69c..a1f87f889 100644 --- a/src/font/sprite/Face.zig +++ b/src/font/sprite/Face.zig @@ -30,6 +30,7 @@ metrics: font.Metrics, pub const DrawFnError = Allocator.Error || + z2d.Path.Error || z2d.painter.FillError || z2d.painter.StrokeError || error{ diff --git a/src/font/sprite/draw/special.zig b/src/font/sprite/draw/special.zig index c1d795b9f..419b7ce79 100644 --- a/src/font/sprite/draw/special.zig +++ b/src/font/sprite/draw/special.zig @@ -20,11 +20,19 @@ pub fn underline( metrics: font.Metrics, ) !void { _ = cp; - _ = height; + + // We can go beyond the height of the cell a bit, but + // we want to be sure never to exceed the height of the + // canvas, which extends a quarter cell below the cell + // height. + const y = @min( + metrics.underline_position, + height +| canvas.padding_y -| metrics.underline_thickness, + ); canvas.rect(.{ .x = 0, - .y = @intCast(metrics.underline_position), + .y = @intCast(y), .width = @intCast(width), .height = @intCast(metrics.underline_thickness), }, .on); @@ -38,20 +46,28 @@ pub fn underline_double( metrics: font.Metrics, ) !void { _ = cp; - _ = height; + + // We can go beyond the height of the cell a bit, but + // we want to be sure never to exceed the height of the + // canvas, which extends a quarter cell below the cell + // height. + const y = @min( + metrics.underline_position, + height +| canvas.padding_y -| 2 * metrics.underline_thickness, + ); // We place one underline above the underline position, and one below // by one thickness, creating a "negative" underline where the single // underline would be placed. canvas.rect(.{ .x = 0, - .y = @intCast(metrics.underline_position -| metrics.underline_thickness), + .y = @intCast(y -| metrics.underline_thickness), .width = @intCast(width), .height = @intCast(metrics.underline_thickness), }, .on); canvas.rect(.{ .x = 0, - .y = @intCast(metrics.underline_position +| metrics.underline_thickness), + .y = @intCast(y +| metrics.underline_thickness), .width = @intCast(width), .height = @intCast(metrics.underline_thickness), }, .on); @@ -65,12 +81,32 @@ pub fn underline_dotted( metrics: font.Metrics, ) !void { _ = cp; - _ = height; - // TODO: Rework this now that we can go out of bounds, just - // make sure that adjacent versions of this glyph align. - const dot_width = @max(metrics.underline_thickness, 3); - const dot_count = @max((width / dot_width) / 2, 1); + // We can go beyond the height of the cell a bit, but + // we want to be sure never to exceed the height of the + // canvas, which extends a quarter cell below the cell + // height. + const y = @min( + metrics.underline_position, + height +| canvas.padding_y -| metrics.underline_thickness, + ); + + const dot_width = @max( + // Dots should be at least as thick as the underline. + metrics.underline_thickness, + // At least as thick as a quarter of the cell, since + // less than that starts to look a little bit silly. + metrics.cell_width / 4, + // And failing all else, be at least 1 pixel wide. + 1, + ); + const dot_count = @max( + // We should try to have enough dots that the + // space between them is the same as their size. + (width / dot_width) / 2, + // And we must have at least one dot per cell. + 1, + ); const gap_width = std.math.divCeil( u32, width -| (dot_count * dot_width), @@ -78,13 +114,11 @@ pub fn underline_dotted( ) catch return error.MathError; var i: u32 = 0; while (i < dot_count) : (i += 1) { - // Ensure we never go out of bounds for the rect - const x = @min(i * (dot_width + gap_width), width - 1); - const rect_width = @min(width - x, dot_width); + const x = i * (dot_width + gap_width); canvas.rect(.{ .x = @intCast(x), - .y = @intCast(metrics.underline_position), - .width = @intCast(rect_width), + .y = @intCast(y), + .width = @intCast(dot_width), .height = @intCast(metrics.underline_thickness), }, .on); } @@ -98,19 +132,25 @@ pub fn underline_dashed( metrics: font.Metrics, ) !void { _ = cp; - _ = height; + + // We can go beyond the height of the cell a bit, but + // we want to be sure never to exceed the height of the + // canvas, which extends a quarter cell below the cell + // height. + const y = @min( + metrics.underline_position, + height +| canvas.padding_y -| metrics.underline_thickness, + ); const dash_width = width / 3 + 1; const dash_count = (width / dash_width) + 1; var i: u32 = 0; while (i < dash_count) : (i += 2) { - // Ensure we never go out of bounds for the rect - const x = @min(i * dash_width, width - 1); - const rect_width = @min(width - x, dash_width); + const x = i * dash_width; canvas.rect(.{ .x = @intCast(x), - .y = @intCast(metrics.underline_position), - .width = @intCast(rect_width), + .y = @intCast(y), + .width = @intCast(dash_width), .height = @intCast(metrics.underline_thickness), }, .on); } @@ -124,105 +164,66 @@ pub fn underline_curly( metrics: font.Metrics, ) !void { _ = cp; - _ = height; - // TODO: Rework this using z2d, this is pretty cool code and all but - // it doesn't need to be highly optimized and z2d path drawing - // code would be clearer and nicer to have. + var ctx = canvas.getContext(); + defer ctx.deinit(); const float_width: f64 = @floatFromInt(width); + const float_height: f64 = @floatFromInt(height); + const float_pos: f64 = @floatFromInt(metrics.underline_position); + // Because of we way we draw the undercurl, we end up making it around 1px // thicker than it should be, to fix this we just reduce the thickness by 1. // // We use a minimum thickness of 0.414 because this empirically produces // the nicest undercurls at 1px underline thickness; thinner tends to look // too thin compared to straight underlines and has artefacting. - const float_thick: f64 = @max( - 0.414, - @as(f64, @floatFromInt(metrics.underline_thickness -| 1)), + ctx.line_width = @floatFromInt(metrics.underline_thickness); + + // Rounded caps, adjacent underlines will have these overlap and so not be + // visible, but it makes the ends look cleaner. + ctx.line_cap_mode = .round; + + // Empirically this looks good. + const amplitude = float_width / std.math.pi; + + // Make sure we don't exceed the drawable area. This can still be outside + // of the cell by some amount (one quarter of the height), but we don't + // want underlines to disappear for fonts with bad metadata or when users + // set their underline position way too low. + const padding: f64 = @floatFromInt(canvas.padding_y); + const top: f64 = @min( + float_pos, + // The lowest we can draw this and not get clipped. + float_height + padding - amplitude - ctx.line_width, ); + const bottom = top + amplitude; - // Calculate the wave period for a single character - // `2 * pi...` = 1 peak per character - // `4 * pi...` = 2 peaks per character - const wave_period = 2 * std.math.pi / float_width; + // Curvature multiplier. + // To my eye, 0.4 creates a nice smooth wiggle. + const r = 0.4; - // The full amplitude of the wave can be from the bottom to the - // underline position. We also calculate our mid y point of the wave - const half_amplitude = 1.0 / wave_period; - const y_mid: f64 = half_amplitude + float_thick * 0.5 + 1; + const center = 0.5 * float_width; - // Offset to move the undercurl up slightly. - const y_off: u32 = @intFromFloat(half_amplitude * 0.5); - - // This is used in calculating the offset curve estimate below. - const offset_factor = @min(1.0, float_thick * 0.5 * wave_period) * @min( - 1.0, - half_amplitude * wave_period, + // We create a single cycle of a wave that peaks at the center of the cell. + try ctx.moveTo(0, bottom); + try ctx.curveTo( + center * r, + bottom, + center - center * r, + top, + center, + top, ); - - // follow Xiaolin Wu's antialias algorithm to draw the curve - var x: u32 = 0; - while (x < width) : (x += 1) { - // We sample the wave function at the *middle* of each - // pixel column, to ensure that it renders symmetrically. - const t: f64 = (@as(f64, @floatFromInt(x)) + 0.5) * wave_period; - // Use the slope at this location to add thickness to - // the line on this column, counteracting the thinning - // caused by the slope. - // - // This is not the exact offset curve for a sine wave, - // but it's a decent enough approximation. - // - // How did I derive this? I stared at Desmos and fiddled - // with numbers for an hour until it was good enough. - const t_u: f64 = t + std.math.pi; - const slope_factor_u: f64 = - (@sin(t_u) * @sin(t_u) * offset_factor) / - ((1.0 + @cos(t_u / 2) * @cos(t_u / 2) * 2) * wave_period); - const slope_factor_l: f64 = - (@sin(t) * @sin(t) * offset_factor) / - ((1.0 + @cos(t / 2) * @cos(t / 2) * 2) * wave_period); - - const cosx: f64 = @cos(t); - // This will be the center of our stroke. - const y: f64 = y_mid + half_amplitude * cosx; - - // The upper pixel and lower pixel are - // calculated relative to the center. - const y_u: f64 = y - float_thick * 0.5 - slope_factor_u; - const y_l: f64 = y + float_thick * 0.5 + slope_factor_l; - const y_upper: u32 = @intFromFloat(@floor(y_u)); - const y_lower: u32 = @intFromFloat(@ceil(y_l)); - const alpha_u: u8 = @intFromFloat( - @round(255 * (1.0 - @abs(y_u - @floor(y_u)))), - ); - const alpha_l: u8 = @intFromFloat( - @round(255 * (1.0 - @abs(y_l - @ceil(y_l)))), - ); - - // upper and lower bounds - canvas.pixel( - @intCast(x), - @intCast(metrics.underline_position +| y_upper -| y_off), - @enumFromInt(alpha_u), - ); - canvas.pixel( - @intCast(x), - @intCast(metrics.underline_position +| y_lower -| y_off), - @enumFromInt(alpha_l), - ); - - // fill between upper and lower bound - var y_fill: u32 = y_upper + 1; - while (y_fill < y_lower) : (y_fill += 1) { - canvas.pixel( - @intCast(x), - @intCast(metrics.underline_position +| y_fill -| y_off), - .on, - ); - } - } + try ctx.curveTo( + center + center * r, + top, + float_width - center * r, + bottom, + float_width, + bottom, + ); + try ctx.stroke(); } pub fn strikethrough( @@ -253,9 +254,18 @@ pub fn overline( _ = cp; _ = height; + // We can go beyond the top of the cell a bit, but we + // want to be sure never to exceed the height of the + // canvas, which extends a quarter cell above the top + // of the cell. + const y = @max( + metrics.overline_position, + -@as(i32, @intCast(canvas.padding_y)), + ); + canvas.rect(.{ .x = 0, - .y = @intCast(metrics.overline_position), + .y = y, .width = @intCast(width), .height = @intCast(metrics.overline_thickness), }, .on); @@ -335,11 +345,19 @@ pub fn cursor_underline( metrics: font.Metrics, ) !void { _ = cp; - _ = height; + + // We can go beyond the height of the cell a bit, but + // we want to be sure never to exceed the height of the + // canvas, which extends a quarter cell below the cell + // height. + const y = @min( + metrics.underline_position, + height +| canvas.padding_y -| metrics.underline_thickness, + ); canvas.rect(.{ .x = 0, - .y = @intCast(metrics.underline_position), + .y = @intCast(y), .width = @intCast(width), .height = @intCast(metrics.cursor_thickness), }, .on); From 3280cf7d344606a72a2fa6307e89e0c18659e1c1 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 20 Nov 2025 13:36:09 -0700 Subject: [PATCH 045/209] font/sprite: rework dotted underline Draw proper anti-aliased dots now instead of rectangles, thanks to z2d this is very easy to do, and the results are very nice, no more weird gaps in dotted underlines if your cell is the wrong number of pixels across. --- src/font/sprite/draw/special.zig | 70 ++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/src/font/sprite/draw/special.zig b/src/font/sprite/draw/special.zig index 419b7ce79..22d8edb5c 100644 --- a/src/font/sprite/draw/special.zig +++ b/src/font/sprite/draw/special.zig @@ -82,46 +82,56 @@ pub fn underline_dotted( ) !void { _ = cp; + var ctx = canvas.getContext(); + defer ctx.deinit(); + + const float_width: f64 = @floatFromInt(width); + const float_height: f64 = @floatFromInt(height); + const float_pos: f64 = @floatFromInt(metrics.underline_position); + const float_thick: f64 = @floatFromInt(metrics.underline_thickness); + + // The diameter will be sqrt2 * the usual underline thickness + // since otherwise dotted underlines look somewhat anemic. + const radius = std.math.sqrt1_2 * float_thick; + // We can go beyond the height of the cell a bit, but // we want to be sure never to exceed the height of the // canvas, which extends a quarter cell below the cell // height. + const padding: f64 = @floatFromInt(canvas.padding_y); const y = @min( - metrics.underline_position, - height +| canvas.padding_y -| metrics.underline_thickness, + // The center of the underline stem. + float_pos + 0.5 * float_thick, + // The lowest we can go on the canvas and not get clipped. + float_height + padding - @ceil(radius), ); - const dot_width = @max( - // Dots should be at least as thick as the underline. - metrics.underline_thickness, - // At least as thick as a quarter of the cell, since - // less than that starts to look a little bit silly. - metrics.cell_width / 4, - // And failing all else, be at least 1 pixel wide. - 1, - ); - const dot_count = @max( - // We should try to have enough dots that the - // space between them is the same as their size. - (width / dot_width) / 2, + const dot_count: f64 = @max( + @min( + // We should try to have enough dots that the + // space between them matches their diameter. + @ceil(float_width / (4 * radius)), + // And not enough that the space between + // each dot is less than their radius. + @floor(float_width / (3 * radius)), + // And definitely not enough that the space + // between them is less than a single pixel. + @floor(float_width / (2 * radius + 1)), + ), // And we must have at least one dot per cell. - 1, + 1.0, ); - const gap_width = std.math.divCeil( - u32, - width -| (dot_count * dot_width), - dot_count, - ) catch return error.MathError; - var i: u32 = 0; - while (i < dot_count) : (i += 1) { - const x = i * (dot_width + gap_width); - canvas.rect(.{ - .x = @intCast(x), - .y = @intCast(y), - .width = @intCast(dot_width), - .height = @intCast(metrics.underline_thickness), - }, .on); + + // What we essentially do is divide the cell in to + // dot_count areas with a dot centered in each one. + var x: f64 = (float_width / dot_count) / 2; + for (0..@as(usize, @intFromFloat(dot_count))) |_| { + try ctx.arc(x, y, radius, 0.0, std.math.tau); + try ctx.closePath(); + x += float_width / dot_count; } + + try ctx.fill(); } pub fn underline_dashed( From 491e7245867f5b8a4bbe845cc4cb50c695e0c16f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Nov 2025 00:07:57 +0000 Subject: [PATCH 046/209] build(deps): bump actions/checkout from 5.0.1 to 6.0.0 Bumps [actions/checkout](https://github.com/actions/checkout) from 5.0.1 to 6.0.0. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/93cb6efe18208431cddfb8368fd83d5badbf9bfd...1af3b93b6815bc44a9784bd300feb67ff0d1eeb3) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/release-tag.yml | 8 +-- .github/workflows/release-tip.yml | 18 +++---- .github/workflows/test.yml | 62 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 5 files changed, 46 insertions(+), 46 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index a75a8c69d..d8b9d2c18 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -34,7 +34,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index aa6422b69..50892a151 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -56,7 +56,7 @@ jobs: fi - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: # Important so that build number generation works fetch-depth: 0 @@ -80,7 +80,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -132,7 +132,7 @@ jobs: GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }} steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - uses: DeterminateSystems/nix-installer-action@main with: @@ -306,7 +306,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Download macOS Artifacts uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index b2b6e44a6..a8a7f641f 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -29,7 +29,7 @@ jobs: commit: ${{ steps.extract_build_info.outputs.commit }} commit_long: ${{ steps.extract_build_info.outputs.commit_long }} steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: # Important so that build number generation works fetch-depth: 0 @@ -66,7 +66,7 @@ jobs: needs: [setup, build-macos] if: needs.setup.outputs.should_skip != 'true' steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Tip Tag run: | git config user.name "github-actions[bot]" @@ -81,7 +81,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Install sentry-cli run: | @@ -104,7 +104,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Install sentry-cli run: | @@ -127,7 +127,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Install sentry-cli run: | @@ -159,7 +159,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -217,7 +217,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: # Important so that build number generation works fetch-depth: 0 @@ -451,7 +451,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: # Important so that build number generation works fetch-depth: 0 @@ -635,7 +635,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: # Important so that build number generation works fetch-depth: 0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 204fd49f8..9b6acd385 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,7 +69,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -112,7 +112,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -145,7 +145,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -179,7 +179,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -222,7 +222,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -258,7 +258,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -287,7 +287,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -320,7 +320,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -366,7 +366,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -404,7 +404,7 @@ jobs: needs: [build-dist, build-snap] steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Trigger Snap workflow run: | @@ -421,7 +421,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -464,7 +464,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -509,7 +509,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 # This could be from a script if we wanted to but inlining here for now # in one place. @@ -580,7 +580,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Get required Zig version id: zig @@ -627,7 +627,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -675,7 +675,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -710,7 +710,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -737,7 +737,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -774,7 +774,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -804,7 +804,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -832,7 +832,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -859,7 +859,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -886,7 +886,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -913,7 +913,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -940,7 +940,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -974,7 +974,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -1001,7 +1001,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -1035,7 +1035,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -1104,7 +1104,7 @@ jobs: runs-on: ${{ matrix.variant.runner }} needs: test steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6 with: bundle: com.mitchellh.ghostty @@ -1123,7 +1123,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -1162,7 +1162,7 @@ jobs: # timeout-minutes: 10 # steps: # - name: Checkout Ghostty - # uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + # uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 # # - name: Start SSH # run: | diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 20f80adc4..595d5f1f2 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -17,7 +17,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: fetch-depth: 0 From a90fe1656a461658df396f0417abfa0a7f40da8c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 17 Nov 2025 13:05:00 -1000 Subject: [PATCH 047/209] terminal: RenderState --- src/terminal/main.zig | 2 + src/terminal/render.zig | 262 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 src/terminal/render.zig diff --git a/src/terminal/main.zig b/src/terminal/main.zig index d57bd6530..77a96bfee 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -5,6 +5,7 @@ const stream = @import("stream.zig"); const ansi = @import("ansi.zig"); const csi = @import("csi.zig"); const hyperlink = @import("hyperlink.zig"); +const render = @import("render.zig"); const stream_readonly = @import("stream_readonly.zig"); const style = @import("style.zig"); pub const apc = @import("apc.zig"); @@ -40,6 +41,7 @@ pub const Pin = PageList.Pin; pub const Point = point.Point; pub const ReadonlyHandler = stream_readonly.Handler; pub const ReadonlyStream = stream_readonly.Stream; +pub const RenderState = render.RenderState; pub const Screen = @import("Screen.zig"); pub const ScreenSet = @import("ScreenSet.zig"); pub const Scrollbar = PageList.Scrollbar; diff --git a/src/terminal/render.zig b/src/terminal/render.zig new file mode 100644 index 000000000..e2f9c84ef --- /dev/null +++ b/src/terminal/render.zig @@ -0,0 +1,262 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const size = @import("size.zig"); +const page = @import("page.zig"); +const Screen = @import("Screen.zig"); +const ScreenSet = @import("ScreenSet.zig"); +const Style = @import("style.zig").Style; +const Terminal = @import("Terminal.zig"); + +// Developer note: this is in src/terminal and not src/renderer because +// the goal is that this remains generic to multiple renderers. This can +// aid specifically with libghostty-vt with converting terminal state to +// a renderable form. + +/// Contains the state required to render the screen, including optimizing +/// for repeated render calls and only rendering dirty regions. +/// +/// Previously, our renderer would use `clone` to clone the screen within +/// the viewport to perform rendering. This worked well enough that we kept +/// it all the way up through the Ghostty 1.2.x series, but the clone time +/// was repeatedly a bottleneck blocking IO. +/// +/// Rather than a generic clone that tries to clone all screen state per call +/// (within a region), a stateful approach that optimizes for only what a +/// renderer needs to do makes more sense. +pub const RenderState = struct { + /// The current screen dimensions. It is possible that these don't match + /// the renderer's current dimensions in grid cells because resizing + /// can happen asynchronously. For example, for Metal, our NSView resizes + /// at a different time than when our internal terminal state resizes. + /// This can lead to a one or two frame mismatch a renderer needs to + /// handle. + /// + /// The viewport is always exactly equal to the active area size so this + /// is also the viewport size. + rows: size.CellCountInt, + cols: size.CellCountInt, + + /// The viewport is at the bottom of the terminal, viewing the active + /// area and scrolling with new output. + viewport_is_bottom: bool, + + /// The rows (y=0 is top) of the viewport. + row_data: std.ArrayList(Row), + + /// The screen type that this state represents. This is used primarily + /// to detect changes. + screen: ScreenSet.Key, + + /// Initial state. + pub const empty: RenderState = .{ + .rows = 0, + .cols = 0, + .viewport_is_bottom = false, + .row_data = .empty, + .screen = .primary, + }; + + /// A row within the viewport. + pub const Row = struct { + /// Arena used for any heap allocations for this row, + arena: ArenaAllocator.State, + + /// The cells in this row, always `cols`` length. + cells: std.MultiArrayList(Cell), + }; + + pub const Cell = struct { + content: union(enum) { + empty, + single: u21, + slice: []const u21, + }, + wide: page.Cell.Wide, + style: Style, + }; + + pub fn deinit(self: *RenderState, alloc: Allocator) void { + for (self.row_data.items) |row| { + var arena: ArenaAllocator = row.arena.promote(alloc); + arena.deinit(); + } + self.row_data.deinit(alloc); + } + + /// Update the render state to the latest terminal state. + /// + /// This will reset the terminal dirty state since it is consumed + /// by this render state update. + pub fn update( + self: *RenderState, + alloc: Allocator, + t: *Terminal, + ) Allocator.Error!void { + const full_rebuild: bool = rebuild: { + // If our screen key changed, we need to do a full rebuild + // because our render state is viewport-specific. + if (t.screens.active_key != self.screen) break :rebuild true; + + // If our terminal is dirty at all, we do a full rebuild. These + // dirty values are full-terminal dirty values. + { + const Int = @typeInfo(Terminal.Dirty).@"struct".backing_integer.?; + const v: Int = @bitCast(t.flags.dirty); + if (v > 0) break :rebuild true; + } + + // If our screen is dirty at all, we do a full rebuild. This is + // a full screen dirty tracker. + { + const Int = @typeInfo(Screen.Dirty).@"struct".backing_integer.?; + const v: Int = @bitCast(t.screens.active.dirty); + if (v > 0) break :rebuild true; + } + + break :rebuild false; + }; + + // Full rebuild resets our state completely. + if (full_rebuild) { + self.* = .empty; + self.screen = t.screens.active_key; + } + + const s: *Screen = t.screens.active; + + // Always set our cheap fields, its more expensive to compare + self.rows = s.pages.rows; + self.cols = s.pages.cols; + self.viewport_is_bottom = s.viewportIsBottom(); + + // Ensure our row length is exactly our height, freeing or allocating + // data as necessary. + if (self.row_data.items.len <= self.rows) { + @branchHint(.likely); + try self.row_data.ensureTotalCapacity(alloc, self.rows); + for (self.row_data.items.len..self.rows) |_| { + self.row_data.appendAssumeCapacity(.{ + .arena = .{}, + .cells = .empty, + }); + } + } else { + for (self.row_data.items[self.rows..]) |row| { + var arena: ArenaAllocator = row.arena.promote(alloc); + arena.deinit(); + } + self.row_data.shrinkRetainingCapacity(self.rows); + } + + // Go through and setup our rows. + var row_it = s.pages.rowIterator( + .left_up, + .{ .viewport = .{} }, + null, + ); + var y: size.CellCountInt = 0; + while (row_it.next()) |row_pin| : (y = y + 1) { + // If the row isn't dirty then we assume it is unchanged. + if (!full_rebuild and !row_pin.isDirty()) continue; + + // If we have an existing row, reuse it. Guaranteed to exist + // because we setup our row data above. + const row: *Row = &self.row_data.items[y]; + + // Promote our arena. State is copied by value so we need to + // restore it on all exit paths so we don't leak memory. + var arena = row.arena.promote(alloc); + defer row.arena = arena.state; + const arena_alloc = arena.allocator(); + + // Reset our cells if we're rebuilding this row. + if (row.cells.len > 0) { + _ = arena.reset(.retain_capacity); + row.cells = .empty; + } + + // Get all our cells in the page. + const p: *page.Page = &row_pin.node.data; + const page_rac = row_pin.rowAndCell(); + const page_cells: []const page.Cell = p.getCells(page_rac.row); + assert(page_cells.len == self.cols); + + try row.cells.ensureTotalCapacity(arena_alloc, self.cols); + for (page_cells) |*page_cell| { + // Append assuming its a single-codepoint, styled cell + // (most common by far). + row.cells.appendAssumeCapacity(.{ + .content = .{ .single = page_cell.content.codepoint }, + .wide = page_cell.wide, + .style = p.styles.get(p.memory, page_cell.style_id).*, + }); + + // Switch on our content tag to handle less likely cases. + switch (page_cell.content_tag) { + .codepoint => { + @branchHint(.likely); + }, + + // If we have a multi-codepoint grapheme, look it up and + // set our content type. + .codepoint_grapheme => grapheme: { + @branchHint(.unlikely); + + const extra = p.lookupGrapheme(page_cell) orelse break :grapheme; + var cps = try arena_alloc.alloc(u21, extra.len + 1); + cps[0] = page_cell.content.codepoint; + @memcpy(cps[1..], extra); + + const idx = row.cells.len - 1; + var content = row.cells.items(.content); + content[idx] = .{ .slice = cps }; + }, + + .bg_color_rgb => { + @branchHint(.unlikely); + + const idx = row.cells.len - 1; + var content = row.cells.items(.style); + content[idx] = .{ .bg_color = .{ .rgb = .{ + .r = page_cell.content.color_rgb.r, + .g = page_cell.content.color_rgb.g, + .b = page_cell.content.color_rgb.b, + } } }; + }, + + .bg_color_palette => { + @branchHint(.unlikely); + + const idx = row.cells.len - 1; + var content = row.cells.items(.style); + content[idx] = .{ .bg_color = .{ + .palette = page_cell.content.color_palette, + } }; + }, + } + } + } + assert(y == self.rows); + + // Clear our dirty flags + t.flags.dirty = .{}; + s.dirty = .{}; + } +}; + +test { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); +} From 7195cab7d3982e92dd716de4cb812328e24afed8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 17 Nov 2025 14:44:34 -1000 Subject: [PATCH 048/209] benchmark: add RenderState to ScreenClone benchmark --- src/benchmark/ScreenClone.zig | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/benchmark/ScreenClone.zig b/src/benchmark/ScreenClone.zig index 942b08cd1..f59502b12 100644 --- a/src/benchmark/ScreenClone.zig +++ b/src/benchmark/ScreenClone.zig @@ -45,6 +45,9 @@ pub const Mode = enum { /// Full clone clone, + + /// RenderState rather than a screen clone. + render, }; pub fn create( @@ -75,6 +78,7 @@ pub fn benchmark(self: *ScreenClone) Benchmark { .stepFn = switch (self.opts.mode) { .noop => stepNoop, .clone => stepClone, + .render => stepRender, }, .setupFn = setup, .teardownFn = teardown, @@ -153,3 +157,22 @@ fn stepClone(ptr: *anyopaque) Benchmark.Error!void { // to benchmark that. We'll free when the benchmark exits. } } + +fn stepRender(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScreenClone = @ptrCast(@alignCast(ptr)); + + // We loop because its so fast that a single benchmark run doesn't + // properly capture our speeds. + const alloc = self.terminal.screens.active.alloc; + for (0..1000) |_| { + var state: terminalpkg.RenderState = .empty; + state.update(alloc, &self.terminal) catch |err| { + log.warn("error cloning screen err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(state); + + // Note: we purposely do not free memory because we don't want + // to benchmark that. We'll free when the benchmark exits. + } +} From 789b3dd38da872800c3d8bf6450b2b88f2ed9082 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 17 Nov 2025 15:02:57 -1000 Subject: [PATCH 049/209] terminal: RenderState.row_data is a MultiArrayList --- src/terminal/render.zig | 56 +++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/src/terminal/render.zig b/src/terminal/render.zig index e2f9c84ef..5c70cac41 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -43,7 +43,12 @@ pub const RenderState = struct { viewport_is_bottom: bool, /// The rows (y=0 is top) of the viewport. - row_data: std.ArrayList(Row), + /// + /// This is a MultiArrayList because only the update cares about + /// the allocators. Callers care about all the other properties, and + /// this better optimizes cache locality for read access for those + /// use cases. + row_data: std.MultiArrayList(Row), /// The screen type that this state represents. This is used primarily /// to detect changes. @@ -63,7 +68,7 @@ pub const RenderState = struct { /// Arena used for any heap allocations for this row, arena: ArenaAllocator.State, - /// The cells in this row, always `cols`` length. + /// The cells in this row, always `cols` length. cells: std.MultiArrayList(Cell), }; @@ -78,8 +83,8 @@ pub const RenderState = struct { }; pub fn deinit(self: *RenderState, alloc: Allocator) void { - for (self.row_data.items) |row| { - var arena: ArenaAllocator = row.arena.promote(alloc); + for (self.row_data.items(.arena)) |state| { + var arena: ArenaAllocator = state.promote(alloc); arena.deinit(); } self.row_data.deinit(alloc); @@ -133,23 +138,29 @@ pub const RenderState = struct { // Ensure our row length is exactly our height, freeing or allocating // data as necessary. - if (self.row_data.items.len <= self.rows) { + if (self.row_data.len <= self.rows) { @branchHint(.likely); try self.row_data.ensureTotalCapacity(alloc, self.rows); - for (self.row_data.items.len..self.rows) |_| { + for (self.row_data.len..self.rows) |_| { self.row_data.appendAssumeCapacity(.{ .arena = .{}, .cells = .empty, }); } } else { - for (self.row_data.items[self.rows..]) |row| { - var arena: ArenaAllocator = row.arena.promote(alloc); + const arenas = self.row_data.items(.arena); + for (arenas[self.rows..]) |state| { + var arena: ArenaAllocator = state.promote(alloc); arena.deinit(); } self.row_data.shrinkRetainingCapacity(self.rows); } + // Break down our row data + const row_data = self.row_data.slice(); + const row_arenas = row_data.items(.arena); + const row_cells = row_data.items(.cells); + // Go through and setup our rows. var row_it = s.pages.rowIterator( .left_up, @@ -161,20 +172,16 @@ pub const RenderState = struct { // If the row isn't dirty then we assume it is unchanged. if (!full_rebuild and !row_pin.isDirty()) continue; - // If we have an existing row, reuse it. Guaranteed to exist - // because we setup our row data above. - const row: *Row = &self.row_data.items[y]; - // Promote our arena. State is copied by value so we need to // restore it on all exit paths so we don't leak memory. - var arena = row.arena.promote(alloc); - defer row.arena = arena.state; + var arena = row_arenas[y].promote(alloc); + defer row_arenas[y] = arena.state; const arena_alloc = arena.allocator(); // Reset our cells if we're rebuilding this row. - if (row.cells.len > 0) { + if (row_cells[y].len > 0) { _ = arena.reset(.retain_capacity); - row.cells = .empty; + row_cells[y] = .empty; } // Get all our cells in the page. @@ -183,11 +190,12 @@ pub const RenderState = struct { const page_cells: []const page.Cell = p.getCells(page_rac.row); assert(page_cells.len == self.cols); - try row.cells.ensureTotalCapacity(arena_alloc, self.cols); + const cells: *std.MultiArrayList(Cell) = &row_cells[y]; + try cells.ensureTotalCapacity(arena_alloc, self.cols); for (page_cells) |*page_cell| { // Append assuming its a single-codepoint, styled cell // (most common by far). - row.cells.appendAssumeCapacity(.{ + cells.appendAssumeCapacity(.{ .content = .{ .single = page_cell.content.codepoint }, .wide = page_cell.wide, .style = p.styles.get(p.memory, page_cell.style_id).*, @@ -209,16 +217,16 @@ pub const RenderState = struct { cps[0] = page_cell.content.codepoint; @memcpy(cps[1..], extra); - const idx = row.cells.len - 1; - var content = row.cells.items(.content); + const idx = cells.len - 1; + var content = cells.items(.content); content[idx] = .{ .slice = cps }; }, .bg_color_rgb => { @branchHint(.unlikely); - const idx = row.cells.len - 1; - var content = row.cells.items(.style); + const idx = cells.len - 1; + var content = cells.items(.style); content[idx] = .{ .bg_color = .{ .rgb = .{ .r = page_cell.content.color_rgb.r, .g = page_cell.content.color_rgb.g, @@ -229,8 +237,8 @@ pub const RenderState = struct { .bg_color_palette => { @branchHint(.unlikely); - const idx = row.cells.len - 1; - var content = row.cells.items(.style); + const idx = cells.len - 1; + var content = cells.items(.style); content[idx] = .{ .bg_color = .{ .palette = page_cell.content.color_palette, } }; From 60fe4af8aca70f69c68917d90e8771291d78eff7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 17 Nov 2025 15:10:34 -1000 Subject: [PATCH 050/209] terminal: render state style get requires non-default style --- src/terminal/render.zig | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 5c70cac41..b37c3ea04 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -198,7 +198,10 @@ pub const RenderState = struct { cells.appendAssumeCapacity(.{ .content = .{ .single = page_cell.content.codepoint }, .wide = page_cell.wide, - .style = p.styles.get(p.memory, page_cell.style_id).*, + .style = if (page_cell.style_id > 0) p.styles.get( + p.memory, + page_cell.style_id, + ).* else .{}, }); // Switch on our content tag to handle less likely cases. @@ -264,6 +267,9 @@ test { }); defer t.deinit(alloc); + // This fills the screen up + try t.decaln(); + var state: RenderState = .empty; defer state.deinit(alloc); try state.update(alloc, &t); From bbbeacab79b464e567720f307618a8c27e493229 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 18 Nov 2025 05:08:38 -1000 Subject: [PATCH 051/209] terminal: renderstate needs dirty state --- src/terminal/render.zig | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/terminal/render.zig b/src/terminal/render.zig index b37c3ea04..834520df6 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -70,6 +70,11 @@ pub const RenderState = struct { /// The cells in this row, always `cols` length. cells: std.MultiArrayList(Cell), + + /// A dirty flag that can be used by the renderer to track + /// its own draw state. `update` will mark this true whenever + /// this row is changed, too. + dirty: bool, }; pub const Cell = struct { @@ -145,6 +150,7 @@ pub const RenderState = struct { self.row_data.appendAssumeCapacity(.{ .arena = .{}, .cells = .empty, + .dirty = true, }); } } else { @@ -160,6 +166,7 @@ pub const RenderState = struct { const row_data = self.row_data.slice(); const row_arenas = row_data.items(.arena); const row_cells = row_data.items(.cells); + const row_dirties = row_data.items(.dirty); // Go through and setup our rows. var row_it = s.pages.rowIterator( @@ -183,6 +190,7 @@ pub const RenderState = struct { _ = arena.reset(.retain_capacity); row_cells[y] = .empty; } + row_dirties[y] = true; // Get all our cells in the page. const p: *page.Page = &row_pin.node.data; From a66963e3f8ce3e664990ac285b76f59b2278bbfa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 18 Nov 2025 05:29:19 -1000 Subject: [PATCH 052/209] terminal: full redraw state tracking on render state --- src/terminal/render.zig | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 834520df6..c2b9955ca 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -50,6 +50,12 @@ pub const RenderState = struct { /// use cases. row_data: std.MultiArrayList(Row), + /// This is set to true if the terminal state has changed in a way + /// that the renderer should do a full redraw of the grid. The renderer + /// should se this to false when it has done so. `update` will only + /// ever tick this to true. + redraw: bool, + /// The screen type that this state represents. This is used primarily /// to detect changes. screen: ScreenSet.Key, @@ -60,6 +66,7 @@ pub const RenderState = struct { .cols = 0, .viewport_is_bottom = false, .row_data = .empty, + .redraw = false, .screen = .primary, }; @@ -104,17 +111,17 @@ pub const RenderState = struct { alloc: Allocator, t: *Terminal, ) Allocator.Error!void { - const full_rebuild: bool = rebuild: { + self.redraw = redraw: { // If our screen key changed, we need to do a full rebuild // because our render state is viewport-specific. - if (t.screens.active_key != self.screen) break :rebuild true; + if (t.screens.active_key != self.screen) break :redraw true; // If our terminal is dirty at all, we do a full rebuild. These // dirty values are full-terminal dirty values. { const Int = @typeInfo(Terminal.Dirty).@"struct".backing_integer.?; const v: Int = @bitCast(t.flags.dirty); - if (v > 0) break :rebuild true; + if (v > 0) break :redraw true; } // If our screen is dirty at all, we do a full rebuild. This is @@ -122,14 +129,14 @@ pub const RenderState = struct { { const Int = @typeInfo(Screen.Dirty).@"struct".backing_integer.?; const v: Int = @bitCast(t.screens.active.dirty); - if (v > 0) break :rebuild true; + if (v > 0) break :redraw true; } - break :rebuild false; + break :redraw false; }; - // Full rebuild resets our state completely. - if (full_rebuild) { + // Full redraw resets our state completely. + if (self.redraw) { self.* = .empty; self.screen = t.screens.active_key; } @@ -177,7 +184,7 @@ pub const RenderState = struct { var y: size.CellCountInt = 0; while (row_it.next()) |row_pin| : (y = y + 1) { // If the row isn't dirty then we assume it is unchanged. - if (!full_rebuild and !row_pin.isDirty()) continue; + if (!self.redraw and !row_pin.isDirty()) continue; // Promote our arena. State is copied by value so we need to // restore it on all exit paths so we don't leak memory. From 040d7794af83314fa9fad81f773d5fbf10853db8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 18 Nov 2025 05:29:19 -1000 Subject: [PATCH 053/209] renderer: build up render state, rebuild cells with it --- src/renderer/generic.zig | 88 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index b5094b4a3..48c6da54f 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -207,6 +207,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// Our shader pipelines. shaders: Shaders, + /// The render state we update per loop. + terminal_state: terminal.RenderState = .empty, + /// Swap chain which maintains multiple copies of the state needed to /// render a frame, so that we can start building the next frame while /// the previous frame is still being processed on the GPU. @@ -738,6 +741,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } pub fn deinit(self: *Self) void { + self.terminal_state.deinit(self.alloc); + self.swap_chain.deinit(); if (DisplayLink != void) { @@ -1096,6 +1101,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { return; } + // Update our terminal state + try self.terminal_state.update(self.alloc, state.terminal); + // Get our scrollbar out of the terminal. We synchronize // the scrollbar read with frame data updates because this // naturally limits the number of calls to this method (it @@ -2311,6 +2319,86 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } + fn rebuildCells2( + self: *Self, + ) !void { + const state: *terminal.RenderState = &self.terminal_state; + + self.draw_mutex.lock(); + defer self.draw_mutex.unlock(); + + // Handle the case that our grid size doesn't match the terminal + // state grid size. It's possible our backing views for renderers + // have a mismatch temporarily since view resize is handled async + // to terminal state resize and is mostly dependent on GUI + // frameworks. + const grid_size_diff = + self.cells.size.rows != state.rows or + self.cells.size.columns != state.cols; + if (grid_size_diff) { + var new_size = self.cells.size; + new_size.rows = state.rows; + new_size.columns = state.cols; + try self.cells.resize(self.alloc, new_size); + + // Update our uniforms accordingly, otherwise + // our background cells will be out of place. + self.uniforms.grid_size = .{ new_size.columns, new_size.rows }; + } + + // Redraw means we are redrawing the full grid, regardless of + // individual row dirtiness. + const redraw = state.redraw or grid_size_diff; + + if (redraw) { + // If we are doing a full rebuild, then we clear the entire + // cell buffer. + self.cells.reset(); + + // We also reset our padding extension depending on the + // screen type + switch (self.config.padding_color) { + .background => {}, + + // For extension, assume we are extending in all directions. + // For "extend" this may be disabled due to heuristics below. + .extend, .@"extend-always" => { + self.uniforms.padding_extend = .{ + .up = true, + .down = true, + .left = true, + .right = true, + }; + }, + } + } + + // Go through all the rows and rebuild as necessary. If we have + // a size mismatch on the state and our grid we just fill what + // we can from the BOTTOM of the viewport. + const start_idx = state.rows - @min( + state.rows, + self.cells.size.rows, + ); + const row_data = state.row_data.slice(); + for ( + 0.., + row_data.items(.cells)[start_idx..], + row_data.items(.dirty)[start_idx..], + ) |y, *cell, dirty| { + if (!redraw) { + // Only rebuild if we are doing a full rebuild or + // this row is dirty. + if (!dirty) continue; + + // Clear the cells if the row is dirty + self.cells.clear(y); + } + + _ = cell; + } + } + /// Convert the terminal state to GPU cells stored in CPU memory. These /// are then synced to the GPU in the next frame. This only updates CPU /// memory and doesn't touch the GPU. From 3f7cee1e993bbb5d2e19ede224d3cc54adf4f47b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 18 Nov 2025 06:02:08 -1000 Subject: [PATCH 054/209] terminal: render state fixes for empty cells --- src/terminal/render.zig | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src/terminal/render.zig b/src/terminal/render.zig index c2b9955ca..c64f5d660 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -85,13 +85,15 @@ pub const RenderState = struct { }; pub const Cell = struct { - content: union(enum) { + content: Content, + wide: page.Cell.Wide, + style: Style, + + pub const Content = union(enum) { empty, single: u21, slice: []const u21, - }, - wide: page.Cell.Wide, - style: Style, + }; }; pub fn deinit(self: *RenderState, alloc: Allocator) void { @@ -210,8 +212,9 @@ pub const RenderState = struct { for (page_cells) |*page_cell| { // Append assuming its a single-codepoint, styled cell // (most common by far). + const idx = cells.len; cells.appendAssumeCapacity(.{ - .content = .{ .single = page_cell.content.codepoint }, + .content = .empty, // Filled in below .wide = page_cell.wide, .style = if (page_cell.style_id > 0) p.styles.get( p.memory, @@ -223,6 +226,23 @@ pub const RenderState = struct { switch (page_cell.content_tag) { .codepoint => { @branchHint(.likely); + + // It is possible for our codepoint to be zero. If + // that is the case, we set the codepoint to empty. + const cp = page_cell.content.codepoint; + var content = cells.items(.content); + content[idx] = if (cp == 0) zero: { + // Spacers are meaningful and not actually empty + // so we only set empty for truly empty cells. + if (page_cell.wide == .narrow) { + @branchHint(.likely); + break :zero .empty; + } + + break :zero .{ .single = ' ' }; + } else .{ + .single = cp, + }; }, // If we have a multi-codepoint grapheme, look it up and @@ -235,7 +255,6 @@ pub const RenderState = struct { cps[0] = page_cell.content.codepoint; @memcpy(cps[1..], extra); - const idx = cells.len - 1; var content = cells.items(.content); content[idx] = .{ .slice = cps }; }, @@ -243,7 +262,6 @@ pub const RenderState = struct { .bg_color_rgb => { @branchHint(.unlikely); - const idx = cells.len - 1; var content = cells.items(.style); content[idx] = .{ .bg_color = .{ .rgb = .{ .r = page_cell.content.color_rgb.r, @@ -255,7 +273,6 @@ pub const RenderState = struct { .bg_color_palette => { @branchHint(.unlikely); - const idx = cells.len - 1; var content = cells.items(.style); content[idx] = .{ .bg_color = .{ .palette = page_cell.content.color_palette, From a86080132386cefaa4b061e872283149d82050a0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 18 Nov 2025 06:18:51 -1000 Subject: [PATCH 055/209] terminal: updating render state with tests --- src/terminal/render.zig | 50 +++++++++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/src/terminal/render.zig b/src/terminal/render.zig index c64f5d660..0701f537d 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -42,7 +42,7 @@ pub const RenderState = struct { /// area and scrolling with new output. viewport_is_bottom: bool, - /// The rows (y=0 is top) of the viewport. + /// The rows (y=0 is top) of the viewport. Guaranteed to be `rows` length. /// /// This is a MultiArrayList because only the update cares about /// the allocators. Callers care about all the other properties, and @@ -75,7 +75,7 @@ pub const RenderState = struct { /// Arena used for any heap allocations for this row, arena: ArenaAllocator.State, - /// The cells in this row, always `cols` length. + /// The cells in this row. Guaranteed to be `cols` length. cells: std.MultiArrayList(Cell), /// A dirty flag that can be used by the renderer to track @@ -113,7 +113,8 @@ pub const RenderState = struct { alloc: Allocator, t: *Terminal, ) Allocator.Error!void { - self.redraw = redraw: { + const s: *Screen = t.screens.active; + const redraw = redraw: { // If our screen key changed, we need to do a full rebuild // because our render state is viewport-specific. if (t.screens.active_key != self.screen) break :redraw true; @@ -134,17 +135,23 @@ pub const RenderState = struct { if (v > 0) break :redraw true; } + // If our dimensions changed, we do a full rebuild. + if (self.rows != s.pages.rows or + self.cols != s.pages.cols) + { + break :redraw true; + } + break :redraw false; }; // Full redraw resets our state completely. - if (self.redraw) { + if (redraw) { self.* = .empty; self.screen = t.screens.active_key; + self.redraw = true; } - const s: *Screen = t.screens.active; - // Always set our cheap fields, its more expensive to compare self.rows = s.pages.rows; self.cols = s.pages.cols; @@ -186,7 +193,7 @@ pub const RenderState = struct { var y: size.CellCountInt = 0; while (row_it.next()) |row_pin| : (y = y + 1) { // If the row isn't dirty then we assume it is unchanged. - if (!self.redraw and !row_pin.isDirty()) continue; + if (!redraw and !row_pin.isDirty()) continue; // Promote our arena. State is copied by value so we need to // restore it on all exit paths so we don't leak memory. @@ -306,3 +313,32 @@ test { defer state.deinit(alloc); try state.update(alloc, &t); } + +test "basic text" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("ABCD"); + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Verify we have the right number of rows + const row_data = state.row_data.slice(); + try testing.expectEqual(3, row_data.len); + + // All rows should have cols cells + const cells = row_data.items(.cells); + try testing.expectEqual(10, cells[0].len); + try testing.expectEqual(10, cells[1].len); + try testing.expectEqual(10, cells[2].len); +} From 5d85f2382ef585c19c7c51e453fffb22b3da303d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 18 Nov 2025 06:44:33 -1000 Subject: [PATCH 056/209] terminal: render state needs to preserve as much allocation as possible --- src/benchmark/ScreenClone.zig | 15 +++++++- src/terminal/render.zig | 69 ++++++++++++++++++++++++----------- 2 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/benchmark/ScreenClone.zig b/src/benchmark/ScreenClone.zig index f59502b12..eee14090c 100644 --- a/src/benchmark/ScreenClone.zig +++ b/src/benchmark/ScreenClone.zig @@ -161,11 +161,22 @@ fn stepClone(ptr: *anyopaque) Benchmark.Error!void { fn stepRender(ptr: *anyopaque) Benchmark.Error!void { const self: *ScreenClone = @ptrCast(@alignCast(ptr)); + // We do this once out of the loop because a significant slowdown + // on the first run is allocation. After that first run, even with + // a full rebuild, it is much faster. Let's ignore that first run + // slowdown. + const alloc = self.terminal.screens.active.alloc; + var state: terminalpkg.RenderState = .empty; + state.update(alloc, &self.terminal) catch |err| { + log.warn("error cloning screen err={}", .{err}); + return error.BenchmarkFailed; + }; + // We loop because its so fast that a single benchmark run doesn't // properly capture our speeds. - const alloc = self.terminal.screens.active.alloc; for (0..1000) |_| { - var state: terminalpkg.RenderState = .empty; + // Forces a full rebuild because it thinks our screen changed + state.screen = .alternate; state.update(alloc, &self.terminal) catch |err| { log.warn("error cloning screen err={}", .{err}); return error.BenchmarkFailed; diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 0701f537d..e32c9454a 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -72,7 +72,11 @@ pub const RenderState = struct { /// A row within the viewport. pub const Row = struct { - /// Arena used for any heap allocations for this row, + /// Arena used for any heap allocations for cell contents + /// in this row. Importantly, this is NOT used for the MultiArrayList + /// itself. We do this on purpose so that we can easily clear rows, + /// but retain cached MultiArrayList capacities since grid sizes don't + /// change often. arena: ArenaAllocator.State, /// The cells in this row. Guaranteed to be `cols` length. @@ -97,9 +101,13 @@ pub const RenderState = struct { }; pub fn deinit(self: *RenderState, alloc: Allocator) void { - for (self.row_data.items(.arena)) |state| { + for ( + self.row_data.items(.arena), + self.row_data.items(.cells), + ) |state, *cells| { var arena: ArenaAllocator = state.promote(alloc); arena.deinit(); + cells.deinit(alloc); } self.row_data.deinit(alloc); } @@ -147,9 +155,11 @@ pub const RenderState = struct { // Full redraw resets our state completely. if (redraw) { - self.* = .empty; self.screen = t.screens.active_key; self.redraw = true; + + // Note: we don't clear any row_data here because our rebuild + // below is going to do that for us. } // Always set our cheap fields, its more expensive to compare @@ -158,24 +168,31 @@ pub const RenderState = struct { self.viewport_is_bottom = s.viewportIsBottom(); // Ensure our row length is exactly our height, freeing or allocating - // data as necessary. - if (self.row_data.len <= self.rows) { - @branchHint(.likely); - try self.row_data.ensureTotalCapacity(alloc, self.rows); - for (self.row_data.len..self.rows) |_| { - self.row_data.appendAssumeCapacity(.{ - .arena = .{}, - .cells = .empty, - .dirty = true, - }); + // data as necessary. In most cases we'll have a perfectly matching + // size. + if (self.row_data.len != self.rows) { + @branchHint(.unlikely); + + if (self.row_data.len < self.rows) { + try self.row_data.ensureTotalCapacity(alloc, self.rows); + for (self.row_data.len..self.rows) |_| { + self.row_data.appendAssumeCapacity(.{ + .arena = .{}, + .cells = .empty, + .dirty = true, + }); + } + } else { + for ( + self.row_data.items(.arena)[self.rows..], + self.row_data.items(.cells)[self.rows..], + ) |state, *cell| { + var arena: ArenaAllocator = state.promote(alloc); + arena.deinit(); + cell.deinit(alloc); + } + self.row_data.shrinkRetainingCapacity(self.rows); } - } else { - const arenas = self.row_data.items(.arena); - for (arenas[self.rows..]) |state| { - var arena: ArenaAllocator = state.promote(alloc); - arena.deinit(); - } - self.row_data.shrinkRetainingCapacity(self.rows); } // Break down our row data @@ -204,7 +221,7 @@ pub const RenderState = struct { // Reset our cells if we're rebuilding this row. if (row_cells[y].len > 0) { _ = arena.reset(.retain_capacity); - row_cells[y] = .empty; + row_cells[y].clearRetainingCapacity(); } row_dirties[y] = true; @@ -214,8 +231,16 @@ pub const RenderState = struct { const page_cells: []const page.Cell = p.getCells(page_rac.row); assert(page_cells.len == self.cols); + // Note: our cells MultiArrayList uses our general allocator. + // We do this on purpose because as rows become dirty, we do + // not want to reallocate space for cells (which are large). This + // was a source of huge slowdown. + // + // Our per-row arena is only used for temporary allocations + // pertaining to cells directly (e.g. graphemes, hyperlinks). const cells: *std.MultiArrayList(Cell) = &row_cells[y]; - try cells.ensureTotalCapacity(arena_alloc, self.cols); + try cells.ensureTotalCapacity(alloc, self.cols); + for (page_cells) |*page_cell| { // Append assuming its a single-codepoint, styled cell // (most common by far). From 4caefb807c578a6a974566f428b4da49e0280fbf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 18 Nov 2025 13:11:19 -1000 Subject: [PATCH 057/209] terminal: fix up some performance issues with render state --- src/benchmark/ScreenClone.zig | 7 +++ src/terminal/page.zig | 5 +- src/terminal/render.zig | 96 ++++++++++++++++------------------- 3 files changed, 54 insertions(+), 54 deletions(-) diff --git a/src/benchmark/ScreenClone.zig b/src/benchmark/ScreenClone.zig index eee14090c..7225aff4e 100644 --- a/src/benchmark/ScreenClone.zig +++ b/src/benchmark/ScreenClone.zig @@ -91,6 +91,13 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { // Always reset our terminal state self.terminal.fullReset(); + // Force a style on every single row, which + var s = self.terminal.vtStream(); + defer s.deinit(); + s.nextSlice("\x1b[48;2;20;40;60m") catch unreachable; + for (0..self.terminal.rows - 1) |_| s.nextSlice("hello\r\n") catch unreachable; + s.nextSlice("hello") catch unreachable; + // Setup our terminal state const data_f: std.fs.File = (options.dataFile( self.opts.data, diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 7364dc527..f9e11e306 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1800,8 +1800,9 @@ pub const Row = packed struct(u64) { /// Returns true if this row has any managed memory outside of the /// row structure (graphemes, styles, etc.) - inline fn managedMemory(self: Row) bool { - return self.grapheme or self.styled or self.hyperlink; + pub inline fn managedMemory(self: Row) bool { + // Ordered on purpose for likelyhood. + return self.styled or self.hyperlink or self.grapheme; } }; diff --git a/src/terminal/render.zig b/src/terminal/render.zig index e32c9454a..a562a270e 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -2,6 +2,7 @@ const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; +const fastmem = @import("../fastmem.zig"); const size = @import("size.zig"); const page = @import("page.zig"); const Screen = @import("Screen.zig"); @@ -89,15 +90,19 @@ pub const RenderState = struct { }; pub const Cell = struct { - content: Content, - wide: page.Cell.Wide, - style: Style, + /// Always set, this is the raw copied cell data from page.Cell. + /// The managed memory (hyperlinks, graphames, etc.) is NOT safe + /// to access from here. It is duplicated into the other fields if + /// it exists. + raw: page.Cell, - pub const Content = union(enum) { - empty, - single: u21, - slice: []const u21, - }; + /// Grapheme data for the cell. This is undefined unless the + /// raw cell's content_tag is `codepoint_grapheme`. + grapheme: []const u21, + + /// The style data for the cell. This is undefined unless + /// the style_id is non-default on raw. + style: Style, }; pub fn deinit(self: *RenderState, alloc: Allocator) void { @@ -216,7 +221,6 @@ pub const RenderState = struct { // restore it on all exit paths so we don't leak memory. var arena = row_arenas[y].promote(alloc); defer row_arenas[y] = arena.state; - const arena_alloc = arena.allocator(); // Reset our cells if we're rebuilding this row. if (row_cells[y].len > 0) { @@ -239,63 +243,53 @@ pub const RenderState = struct { // Our per-row arena is only used for temporary allocations // pertaining to cells directly (e.g. graphemes, hyperlinks). const cells: *std.MultiArrayList(Cell) = &row_cells[y]; - try cells.ensureTotalCapacity(alloc, self.cols); + try cells.resize(alloc, self.cols); - for (page_cells) |*page_cell| { + // We always copy our raw cell data. In the case we have no + // managed memory, we can skip setting any other fields. + // + // This is an important optimization. For plain-text screens + // this ends up being something around 300% faster based on + // the `screen-clone` benchmark. + const cells_slice = cells.slice(); + fastmem.copy( + page.Cell, + cells_slice.items(.raw), + page_cells, + ); + if (!page_rac.row.managedMemory()) continue; + + const arena_alloc = arena.allocator(); + const cells_grapheme = cells_slice.items(.grapheme); + const cells_style = cells_slice.items(.style); + for (page_cells, 0..) |*page_cell, x| { // Append assuming its a single-codepoint, styled cell // (most common by far). - const idx = cells.len; - cells.appendAssumeCapacity(.{ - .content = .empty, // Filled in below - .wide = page_cell.wide, - .style = if (page_cell.style_id > 0) p.styles.get( - p.memory, - page_cell.style_id, - ).* else .{}, - }); + if (page_cell.style_id > 0) cells_style[x] = p.styles.get( + p.memory, + page_cell.style_id, + ).*; // Switch on our content tag to handle less likely cases. switch (page_cell.content_tag) { .codepoint => { @branchHint(.likely); - - // It is possible for our codepoint to be zero. If - // that is the case, we set the codepoint to empty. - const cp = page_cell.content.codepoint; - var content = cells.items(.content); - content[idx] = if (cp == 0) zero: { - // Spacers are meaningful and not actually empty - // so we only set empty for truly empty cells. - if (page_cell.wide == .narrow) { - @branchHint(.likely); - break :zero .empty; - } - - break :zero .{ .single = ' ' }; - } else .{ - .single = cp, - }; + // Primary codepoint goes into `raw` field. }, // If we have a multi-codepoint grapheme, look it up and // set our content type. - .codepoint_grapheme => grapheme: { + .codepoint_grapheme => { @branchHint(.unlikely); - - const extra = p.lookupGrapheme(page_cell) orelse break :grapheme; - var cps = try arena_alloc.alloc(u21, extra.len + 1); - cps[0] = page_cell.content.codepoint; - @memcpy(cps[1..], extra); - - var content = cells.items(.content); - content[idx] = .{ .slice = cps }; + cells_grapheme[x] = try arena_alloc.dupe( + u21, + p.lookupGrapheme(page_cell) orelse &.{}, + ); }, .bg_color_rgb => { @branchHint(.unlikely); - - var content = cells.items(.style); - content[idx] = .{ .bg_color = .{ .rgb = .{ + cells_style[x] = .{ .bg_color = .{ .rgb = .{ .r = page_cell.content.color_rgb.r, .g = page_cell.content.color_rgb.g, .b = page_cell.content.color_rgb.b, @@ -304,9 +298,7 @@ pub const RenderState = struct { .bg_color_palette => { @branchHint(.unlikely); - - var content = cells.items(.style); - content[idx] = .{ .bg_color = .{ + cells_style[x] = .{ .bg_color = .{ .palette = page_cell.content.color_palette, } }; }, From 0e13fd6b737a72ccab42d48b4e0b8afa5e699bf8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 18 Nov 2025 13:40:59 -1000 Subject: [PATCH 058/209] terminal: add more render state tests --- src/terminal/render.zig | 104 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 2 deletions(-) diff --git a/src/terminal/render.zig b/src/terminal/render.zig index a562a270e..5e249c1bf 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -208,7 +208,7 @@ pub const RenderState = struct { // Go through and setup our rows. var row_it = s.pages.rowIterator( - .left_up, + .right_down, .{ .viewport = .{} }, null, ); @@ -313,7 +313,7 @@ pub const RenderState = struct { } }; -test { +test "styled" { const testing = std.testing; const alloc = testing.allocator; @@ -358,4 +358,104 @@ test "basic text" { try testing.expectEqual(10, cells[0].len); try testing.expectEqual(10, cells[1].len); try testing.expectEqual(10, cells[2].len); + + // Row zero should contain our text + try testing.expectEqual('A', cells[0].get(0).raw.codepoint()); + try testing.expectEqual('B', cells[0].get(1).raw.codepoint()); + try testing.expectEqual('C', cells[0].get(2).raw.codepoint()); + try testing.expectEqual('D', cells[0].get(3).raw.codepoint()); + try testing.expectEqual(0, cells[0].get(4).raw.codepoint()); +} + +test "styled text" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("\x1b[1mA"); // Bold + try s.nextSlice("\x1b[0;3mB"); // Italic + try s.nextSlice("\x1b[0;4mC"); // Underline + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Verify we have the right number of rows + const row_data = state.row_data.slice(); + try testing.expectEqual(3, row_data.len); + + // All rows should have cols cells + const cells = row_data.items(.cells); + try testing.expectEqual(10, cells[0].len); + try testing.expectEqual(10, cells[1].len); + try testing.expectEqual(10, cells[2].len); + + // Row zero should contain our text + { + const cell = cells[0].get(0); + try testing.expectEqual('A', cell.raw.codepoint()); + try testing.expect(cell.style.flags.bold); + } + { + const cell = cells[0].get(1); + try testing.expectEqual('B', cell.raw.codepoint()); + try testing.expect(!cell.style.flags.bold); + try testing.expect(cell.style.flags.italic); + } + try testing.expectEqual('C', cells[0].get(2).raw.codepoint()); + try testing.expectEqual(0, cells[0].get(3).raw.codepoint()); +} + +test "grapheme" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("A"); + try s.nextSlice("👨‍"); // this has a ZWJ + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Verify we have the right number of rows + const row_data = state.row_data.slice(); + try testing.expectEqual(3, row_data.len); + + // All rows should have cols cells + const cells = row_data.items(.cells); + try testing.expectEqual(10, cells[0].len); + try testing.expectEqual(10, cells[1].len); + try testing.expectEqual(10, cells[2].len); + + // Row zero should contain our text + { + const cell = cells[0].get(0); + try testing.expectEqual('A', cell.raw.codepoint()); + } + { + const cell = cells[0].get(1); + try testing.expectEqual(0x1F468, cell.raw.codepoint()); + try testing.expectEqual(.wide, cell.raw.wide); + try testing.expectEqualSlices(u21, &.{0x200D}, cell.grapheme); + } + { + const cell = cells[0].get(2); + try testing.expectEqual(0, cell.raw.codepoint()); + try testing.expectEqual(.spacer_tail, cell.raw.wide); + } } From 29db3e0295ea9df467b99ed375c55fde27baf47c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 18 Nov 2025 14:02:56 -1000 Subject: [PATCH 059/209] terminal: setup selection state on render state --- src/terminal/page.zig | 2 +- src/terminal/render.zig | 40 +++++++++++++++++++++++++++++++++++----- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index f9e11e306..bf40d2353 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1801,7 +1801,7 @@ pub const Row = packed struct(u64) { /// Returns true if this row has any managed memory outside of the /// row structure (graphemes, styles, etc.) pub inline fn managedMemory(self: Row) bool { - // Ordered on purpose for likelyhood. + // Ordered on purpose for likelihood. return self.styled or self.hyperlink or self.grapheme; } }; diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 5e249c1bf..30f9c6c62 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -87,6 +87,9 @@ pub const RenderState = struct { /// its own draw state. `update` will mark this true whenever /// this row is changed, too. dirty: bool, + + /// The x range of the selection within this row. + selection: [2]size.CellCountInt, }; pub const Cell = struct { @@ -179,18 +182,27 @@ pub const RenderState = struct { @branchHint(.unlikely); if (self.row_data.len < self.rows) { - try self.row_data.ensureTotalCapacity(alloc, self.rows); - for (self.row_data.len..self.rows) |_| { - self.row_data.appendAssumeCapacity(.{ + // Resize our rows to the desired length, marking any added + // values undefined. + const old_len = self.row_data.len; + try self.row_data.resize(alloc, self.rows); + + // Initialize all our values. Its faster to use slice() + set() + // because appendAssumeCapacity does this multiple times. + var row_data = self.row_data.slice(); + for (old_len..self.rows) |y| { + row_data.set(y, .{ .arena = .{}, .cells = .empty, .dirty = true, + .selection = .{ 0, 0 }, }); } } else { + const row_data = self.row_data.slice(); for ( - self.row_data.items(.arena)[self.rows..], - self.row_data.items(.cells)[self.rows..], + row_data.items(.arena)[self.rows..], + row_data.items(.cells)[self.rows..], ) |state, *cell| { var arena: ArenaAllocator = state.promote(alloc); arena.deinit(); @@ -307,6 +319,24 @@ pub const RenderState = struct { } assert(y == self.rows); + // If our screen has a selection, then mark the rows with the + // selection. + if (s.selection) |*sel| { + @branchHint(.unlikely); + + // TODO: + // - Mark the rows with selections + // - Cache the selection (untracked) so we can avoid redoing + // this expensive work every frame. + + // We need to determine if our selection is within the viewport. + // The viewport is generally very small so the efficient way to + // do this is to traverse the viewport pages and check for the + // matching selection pages. + + _ = sel; + } + // Clear our dirty flags t.flags.dirty = .{}; s.dirty = .{}; From 2d94cd6bbdb169eeffdba7c6736bf196129e1544 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 18 Nov 2025 14:21:17 -1000 Subject: [PATCH 060/209] font: update shaper to support new renderstate --- src/font/shape.zig | 7 + src/font/shaper/coretext.zig | 676 +++++++++++++++++++++++------------ src/font/shaper/run.zig | 342 +++++++++++++++++- 3 files changed, 805 insertions(+), 220 deletions(-) diff --git a/src/font/shape.zig b/src/font/shape.zig index dd0f3dcc5..e3634d68c 100644 --- a/src/font/shape.zig +++ b/src/font/shape.zig @@ -1,3 +1,4 @@ +const std = @import("std"); const builtin = @import("builtin"); const options = @import("main.zig").options; const run = @import("shaper/run.zig"); @@ -72,6 +73,12 @@ pub const RunOptions = struct { /// cached values may be updated during shaping. grid: *SharedGrid, + /// The cells for the row to shape. + cells: std.MultiArrayList(terminal.RenderState.Cell).Slice = .empty, + + /// The x boundaries of the selection in this row. + selection2: ?[2]u16 = null, + /// The terminal screen to shape. screen: *const terminal.Screen, diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 953956eb9..41fa88758 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -625,17 +625,27 @@ test "run iterator" { defer testdata.deinit(); { - // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("ABCD"); + var t: terminal.Terminal = try .init(alloc, .{ + .cols = 5, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("ABCD"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -644,15 +654,23 @@ test "run iterator" { // Spaces should be part of a run { - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("ABCD EFG"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("ABCD EFG"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -661,16 +679,24 @@ test "run iterator" { { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("A😃D"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("A😃D"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -680,16 +706,24 @@ test "run iterator" { // Bad ligatures for (&[_][]const u8{ "fl", "fi", "st" }) |bad| { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(bad); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(bad); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -706,14 +740,18 @@ test "run iterator: empty cells with background set" { { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0 } }); - try screen.testWriteString("A"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + // Set red background + try s.nextSlice("\x1b[48;2;255;0;0m"); + try s.nextSlice("A"); // Get our first row { - const list_cell = screen.pages.getCell(.{ .active = .{ .x = 1 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 1 } }).?; const cell = list_cell.cell; cell.* = .{ .content_tag = .bg_color_rgb, @@ -721,7 +759,7 @@ test "run iterator: empty cells with background set" { }; } { - const list_cell = screen.pages.getCell(.{ .active = .{ .x = 2 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 2 } }).?; const cell = list_cell.cell; cell.* = .{ .content_tag = .bg_color_rgb, @@ -729,12 +767,17 @@ test "run iterator: empty cells with background set" { }; } + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); { const run = (try it.next(alloc)).?; @@ -759,16 +802,24 @@ test "shape" { buf_idx += try std.unicode.utf8Encode(0x1F3FD, buf[buf_idx..]); // Medium skin tone // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -792,16 +843,24 @@ test "shape nerd fonts" { buf_idx += try std.unicode.utf8Encode(' ', buf[buf_idx..]); // space // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -819,15 +878,23 @@ test "shape inconsolata ligs" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(">="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(">="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -842,15 +909,23 @@ test "shape inconsolata ligs" { } { - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("==="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("==="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -873,15 +948,23 @@ test "shape monaspace ligs" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("==="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("==="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -905,15 +988,23 @@ test "shape left-replaced lig in last run" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("!=="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("!=="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -937,15 +1028,23 @@ test "shape left-replaced lig in early run" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("!==X"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("!==X"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); const run = (try it.next(alloc)).?; @@ -966,15 +1065,23 @@ test "shape U+3C9 with JB Mono" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("\u{03C9} foo"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("\u{03C9} foo"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var run_count: usize = 0; @@ -997,15 +1104,23 @@ test "shape emoji width" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("👍"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("👍"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1026,10 +1141,10 @@ test "shape emoji width long" { defer testdata.deinit(); // Make a screen and add a long emoji sequence to it. - var screen = try terminal.Screen.init(alloc, .{ .cols = 30, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); - var page = screen.pages.pages.first.?.data; + var page = t.screens.active.pages.pages.first.?.data; var row = page.getRow(1); const cell = &row.cells.ptr(page.memory)[0]; cell.* = .{ @@ -1048,12 +1163,17 @@ test "shape emoji width long" { graphemes[0..], ); + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 1 } }).?, + .cells = state.row_data.get(1).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1078,16 +1198,24 @@ test "shape variation selector VS15" { buf_idx += try std.unicode.utf8Encode(0xFE0E, buf[buf_idx..]); // ZWJ to force text // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1111,16 +1239,24 @@ test "shape variation selector VS16" { buf_idx += try std.unicode.utf8Encode(0xFE0F, buf[buf_idx..]); // ZWJ to force color // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1139,18 +1275,26 @@ test "shape with empty cells in between" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 30, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("A"); - screen.cursorRight(5); - try screen.testWriteString("B"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("A"); + try s.nextSlice("\x1b[5C"); // 5 spaces forward + try s.nextSlice("B"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1177,16 +1321,24 @@ test "shape Chinese characters" { buf_idx += try std.unicode.utf8Encode('a', buf[buf_idx..]); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 30, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1218,16 +1370,24 @@ test "shape Devanagari string" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 30, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("अपार्टमेंट"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("अपार्टमेंट"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); const run = try it.next(alloc); @@ -1260,16 +1420,24 @@ test "shape box glyphs" { buf_idx += try std.unicode.utf8Encode(0x2501, buf[buf_idx..]); // // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1292,9 +1460,16 @@ test "shape selection boundary" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("a1b2c3d4e5"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("a1b2c3d4e5"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Full line selection { @@ -1302,13 +1477,10 @@ test "shape selection boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - .selection = terminal.Selection.init( - screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, - screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?, - false, - ), + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, + .selection2 = .{ 0, @intCast(t.cols - 1) }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1324,13 +1496,10 @@ test "shape selection boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - .selection = terminal.Selection.init( - screen.pages.pin(.{ .active = .{ .x = 2, .y = 0 } }).?, - screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?, - false, - ), + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, + .selection2 = .{ 2, @intCast(t.cols - 1) }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1346,13 +1515,10 @@ test "shape selection boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - .selection = terminal.Selection.init( - screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, - screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, - false, - ), + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, + .selection2 = .{ 0, 3 }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1368,13 +1534,10 @@ test "shape selection boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - .selection = terminal.Selection.init( - screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, - screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, - false, - ), + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, + .selection2 = .{ 1, 3 }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1390,13 +1553,10 @@ test "shape selection boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - .selection = terminal.Selection.init( - screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, - screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, - false, - ), + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, + .selection2 = .{ 1, 1 }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1415,9 +1575,16 @@ test "shape cursor boundary" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("a1b2c3d4e5"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("a1b2c3d4e5"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // No cursor is full line { @@ -1425,8 +1592,9 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1443,8 +1611,9 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, .cursor_x = 0, }); var count: usize = 0; @@ -1460,8 +1629,9 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1479,8 +1649,9 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, .cursor_x = 1, }); var count: usize = 0; @@ -1496,8 +1667,9 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1514,8 +1686,9 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, .cursor_x = 9, }); var count: usize = 0; @@ -1531,8 +1704,9 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1552,9 +1726,16 @@ test "shape cursor boundary and colored emoji" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 3, .rows = 10, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("👍🏼"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("👍🏼"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // No cursor is full line { @@ -1562,8 +1743,9 @@ test "shape cursor boundary and colored emoji" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1579,8 +1761,9 @@ test "shape cursor boundary and colored emoji" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, .cursor_x = 0, }); var count: usize = 0; @@ -1595,8 +1778,9 @@ test "shape cursor boundary and colored emoji" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1610,8 +1794,9 @@ test "shape cursor boundary and colored emoji" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, .cursor_x = 1, }); var count: usize = 0; @@ -1626,8 +1811,9 @@ test "shape cursor boundary and colored emoji" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1647,15 +1833,23 @@ test "shape cell attribute change" { // Plain >= should shape into 1 run { - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(">="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(">="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1667,17 +1861,25 @@ test "shape cell attribute change" { // Bold vs regular should split { - var screen = try terminal.Screen.init(alloc, .{ .cols = 3, .rows = 10, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(">"); - try screen.setAttribute(.{ .bold = {} }); - try screen.testWriteString("="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(">"); + try s.nextSlice("\x1b[1m"); // Bold + try s.nextSlice("="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1689,18 +1891,28 @@ test "shape cell attribute change" { // Changing fg color should split { - var screen = try terminal.Screen.init(alloc, .{ .cols = 3, .rows = 10, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.setAttribute(.{ .direct_color_fg = .{ .r = 1, .g = 2, .b = 3 } }); - try screen.testWriteString(">"); - try screen.setAttribute(.{ .direct_color_fg = .{ .r = 3, .g = 2, .b = 1 } }); - try screen.testWriteString("="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + // RGB 1, 2, 3 + try s.nextSlice("\x1b[38;2;1;2;3m"); + try s.nextSlice(">"); + // RGB 3, 2, 1 + try s.nextSlice("\x1b[38;2;3;2;1m"); + try s.nextSlice("="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1712,18 +1924,28 @@ test "shape cell attribute change" { // Changing bg color should NOT split { - var screen = try terminal.Screen.init(alloc, .{ .cols = 3, .rows = 10, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); - try screen.testWriteString(">"); - try screen.setAttribute(.{ .direct_color_bg = .{ .r = 3, .g = 2, .b = 1 } }); - try screen.testWriteString("="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + // RGB 1, 2, 3 bg + try s.nextSlice("\x1b[48;2;1;2;3m"); + try s.nextSlice(">"); + // RGB 3, 2, 1 bg + try s.nextSlice("\x1b[48;2;3;2;1m"); + try s.nextSlice("="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1735,17 +1957,26 @@ test "shape cell attribute change" { // Same bg color should not split { - var screen = try terminal.Screen.init(alloc, .{ .cols = 3, .rows = 10, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); - try screen.testWriteString(">"); - try screen.testWriteString("="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + // RGB 1, 2, 3 bg + try s.nextSlice("\x1b[48;2;1;2;3m"); + try s.nextSlice(">"); + try s.nextSlice("="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1773,17 +2004,24 @@ test "shape high plane sprite font codepoint" { var testdata = try testShaper(alloc); defer testdata.deinit(); - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + var s = t.vtStream(); + defer s.deinit(); // U+1FB70: Vertical One Eighth Block-2 - try screen.testWriteString("\u{1FB70}"); + try s.nextSlice("\u{1FB70}"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), + .screen = undefined, + .row = undefined, }); // We should get one run const run = (try it.next(alloc)).?; diff --git a/src/font/shaper/run.zig b/src/font/shaper/run.zig index 79e4bfc18..a0080d1fc 100644 --- a/src/font/shaper/run.zig +++ b/src/font/shaper/run.zig @@ -45,6 +45,8 @@ pub const RunIterator = struct { i: usize = 0, pub fn next(self: *RunIterator, alloc: Allocator) !?TextRun { + if (self.opts.cells.len > 0) return try self.next2(alloc); + const cells = self.opts.row.cells(.all); // Trim the right side of a row that might be empty @@ -309,6 +311,265 @@ pub const RunIterator = struct { }; } + pub fn next2(self: *RunIterator, alloc: Allocator) !?TextRun { + const slice = &self.opts.cells; + const cells: []const terminal.page.Cell = slice.items(.raw); + const graphemes: []const []const u21 = slice.items(.grapheme); + const styles: []const terminal.Style = slice.items(.style); + + // Trim the right side of a row that might be empty + const max: usize = max: { + for (0..cells.len) |i| { + const rev_i = cells.len - i - 1; + if (!cells[rev_i].isEmpty()) break :max rev_i + 1; + } + + break :max 0; + }; + + // Invisible cells don't have any glyphs rendered, + // so we explicitly skip them in the shaping process. + while (self.i < max and + (cells[self.i].hasStyling() and + styles[self.i].flags.invisible)) self.i += 1; + + // We're over at the max + if (self.i >= max) return null; + + // Track the font for our current run + var current_font: font.Collection.Index = .{}; + + // Allow the hook to prepare + try self.hooks.prepare(); + + // Initialize our hash for this run. + var hasher = Hasher.init(0); + + // Let's get our style that we'll expect for the run. + const style: terminal.Style = if (cells[self.i].hasStyling()) styles[self.i] else .{}; + + // Go through cell by cell and accumulate while we build our run. + var j: usize = self.i; + while (j < max) : (j += 1) { + // Use relative cluster positions (offset from run start) to make + // the shaping cache position-independent. This ensures that runs + // with identical content but different starting positions in the + // row produce the same hash, enabling cache reuse. + const cluster = j - self.i; + const cell: *const terminal.page.Cell = &cells[j]; + + // If we have a selection and we're at a boundary point, then + // we break the run here. + if (self.opts.selection2) |bounds| { + if (j > self.i) { + if (bounds[0] > 0 and j == bounds[0]) break; + if (bounds[1] > 0 and j == bounds[1] + 1) break; + } + } + + // If we're a spacer, then we ignore it + switch (cell.wide) { + .narrow, .wide => {}, + .spacer_head, .spacer_tail => continue, + } + + // If our cell attributes are changing, then we split the run. + // This prevents a single glyph for ">=" to be rendered with + // one color when the two components have different styling. + if (j > self.i) style: { + const prev_cell = cells[j - 1]; + + // If the prev cell and this cell are both plain + // codepoints then we check if they are commonly "bad" + // ligatures and spit the run if they are. + if (prev_cell.content_tag == .codepoint and + cell.content_tag == .codepoint) + { + const prev_cp = prev_cell.codepoint(); + switch (prev_cp) { + // fl, fi + 'f' => { + const cp = cell.codepoint(); + if (cp == 'l' or cp == 'i') break; + }, + + // st + 's' => { + const cp = cell.codepoint(); + if (cp == 't') break; + }, + + else => {}, + } + } + + // If the style is exactly the change then fast path out. + if (prev_cell.style_id == cell.style_id) break :style; + + // The style is different. We allow differing background + // styles but any other change results in a new run. + const c1 = comparableStyle(style); + const c2 = comparableStyle(if (cell.hasStyling()) styles[j] else .{}); + if (!c1.eql(c2)) break; + } + + // Text runs break when font styles change so we need to get + // the proper style. + const font_style: font.Style = style: { + if (style.flags.bold) { + if (style.flags.italic) break :style .bold_italic; + break :style .bold; + } + + if (style.flags.italic) break :style .italic; + break :style .regular; + }; + + // Determine the presentation format for this glyph. + const presentation: ?font.Presentation = if (cell.hasGrapheme()) p: { + // We only check the FIRST codepoint because I believe the + // presentation format must be directly adjacent to the codepoint. + const cps = graphemes[j]; + assert(cps.len > 0); + if (cps[0] == 0xFE0E) break :p .text; + if (cps[0] == 0xFE0F) break :p .emoji; + break :p null; + } else emoji: { + // If we're not a grapheme, our individual char could be + // an emoji so we want to check if we expect emoji presentation. + // The font grid indexForCodepoint we use below will do this + // automatically. + break :emoji null; + }; + + // If our cursor is on this line then we break the run around the + // cursor. This means that any row with a cursor has at least + // three breaks: before, exactly the cursor, and after. + // + // We do not break a cell that is exactly the grapheme. If there + // are cells following that contain joiners, we allow those to + // break. This creates an effect where hovering over an emoji + // such as a skin-tone emoji is fine, but hovering over the + // joiners will show the joiners allowing you to modify the + // emoji. + if (!cell.hasGrapheme()) { + if (self.opts.cursor_x) |cursor_x| { + // Exactly: self.i is the cursor and we iterated once. This + // means that we started exactly at the cursor and did at + // exactly one iteration. Why exactly one? Because we may + // start at our cursor but do many if our cursor is exactly + // on an emoji. + if (self.i == cursor_x and j == self.i + 1) break; + + // Before: up to and not including the cursor. This means + // that we started before the cursor (self.i < cursor_x) + // and j is now at the cursor meaning we haven't yet processed + // the cursor. + if (self.i < cursor_x and j == cursor_x) { + assert(j > 0); + break; + } + + // After: after the cursor. We don't need to do anything + // special, we just let the run complete. + } + } + + // We need to find a font that supports this character. If + // there are additional zero-width codepoints (to form a single + // grapheme, i.e. combining characters), we need to find a font + // that supports all of them. + const font_info: struct { + idx: font.Collection.Index, + fallback: ?u32 = null, + } = font_info: { + // If we find a font that supports this entire grapheme + // then we use that. + if (try self.indexForCell2( + alloc, + cell, + graphemes[j], + font_style, + presentation, + )) |idx| break :font_info .{ .idx = idx }; + + // Otherwise we need a fallback character. Prefer the + // official replacement character. + if (try self.opts.grid.getIndex( + alloc, + 0xFFFD, // replacement char + font_style, + presentation, + )) |idx| break :font_info .{ .idx = idx, .fallback = 0xFFFD }; + + // Fallback to space + if (try self.opts.grid.getIndex( + alloc, + ' ', + font_style, + presentation, + )) |idx| break :font_info .{ .idx = idx, .fallback = ' ' }; + + // We can't render at all. This is a bug, we should always + // have a font that can render a space. + unreachable; + }; + + //log.warn("char={x} info={}", .{ cell.char, font_info }); + if (j == self.i) current_font = font_info.idx; + + // If our fonts are not equal, then we're done with our run. + if (font_info.idx != current_font) break; + + // If we're a fallback character, add that and continue; we + // don't want to add the entire grapheme. + if (font_info.fallback) |cp| { + try self.addCodepoint(&hasher, cp, @intCast(cluster)); + continue; + } + + // If we're a Kitty unicode placeholder then we add a blank. + if (cell.codepoint() == terminal.kitty.graphics.unicode.placeholder) { + try self.addCodepoint(&hasher, ' ', @intCast(cluster)); + continue; + } + + // Add all the codepoints for our grapheme + try self.addCodepoint( + &hasher, + if (cell.codepoint() == 0) ' ' else cell.codepoint(), + @intCast(cluster), + ); + if (cell.hasGrapheme()) { + for (graphemes[j]) |cp| { + // Do not send presentation modifiers + if (cp == 0xFE0E or cp == 0xFE0F) continue; + try self.addCodepoint(&hasher, cp, @intCast(cluster)); + } + } + } + + // Finalize our buffer + try self.hooks.finalize(); + + // Add our length to the hash as an additional mechanism to avoid collisions + autoHash(&hasher, j - self.i); + + // Add our font index + autoHash(&hasher, current_font); + + // Move our cursor. Must defer since we use self.i below. + defer self.i = j; + + return .{ + .hash = hasher.final(), + .offset = @intCast(self.i), + .cells = @intCast(j - self.i), + .grid = self.opts.grid, + .font_index = current_font, + }; + } + fn addCodepoint(self: *RunIterator, hasher: anytype, cp: u32, cluster: u32) !void { autoHash(hasher, cp); autoHash(hasher, cluster); @@ -324,7 +585,7 @@ pub const RunIterator = struct { fn indexForCell( self: *RunIterator, alloc: Allocator, - cell: *terminal.Cell, + cell: *const terminal.Cell, style: font.Style, presentation: ?font.Presentation, ) !?font.Collection.Index { @@ -396,6 +657,85 @@ pub const RunIterator = struct { return null; } + + fn indexForCell2( + self: *RunIterator, + alloc: Allocator, + cell: *const terminal.Cell, + graphemes: []const u21, + style: font.Style, + presentation: ?font.Presentation, + ) !?font.Collection.Index { + if (cell.isEmpty() or + cell.codepoint() == 0 or + cell.codepoint() == terminal.kitty.graphics.unicode.placeholder) + { + return try self.opts.grid.getIndex( + alloc, + ' ', + style, + presentation, + ); + } + + // Get the font index for the primary codepoint. + const primary_cp: u32 = cell.codepoint(); + const primary = try self.opts.grid.getIndex( + alloc, + primary_cp, + style, + presentation, + ) orelse return null; + + // Easy, and common: we aren't a multi-codepoint grapheme, so + // we just return whatever index for the cell codepoint. + if (!cell.hasGrapheme()) return primary; + + // If this is a grapheme, we need to find a font that supports + // all of the codepoints in the grapheme. + var candidates: std.ArrayList(font.Collection.Index) = try .initCapacity( + alloc, + graphemes.len + 1, + ); + defer candidates.deinit(alloc); + candidates.appendAssumeCapacity(primary); + + for (graphemes) |cp| { + // Ignore Emoji ZWJs + if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue; + + // Find a font that supports this codepoint. If none support this + // then the whole grapheme can't be rendered so we return null. + // + // We explicitly do not require the additional grapheme components + // to support the base presentation, since it is common for emoji + // fonts to support the base emoji with emoji presentation but not + // certain ZWJ-combined characters like the male and female signs. + const idx = try self.opts.grid.getIndex( + alloc, + cp, + style, + null, + ) orelse return null; + candidates.appendAssumeCapacity(idx); + } + + // We need to find a candidate that has ALL of our codepoints + for (candidates.items) |idx| { + if (!self.opts.grid.hasCodepoint(idx, primary_cp, presentation)) continue; + for (graphemes) |cp| { + // Ignore Emoji ZWJs + if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue; + if (!self.opts.grid.hasCodepoint(idx, cp, null)) break; + } else { + // If the while completed, then we have a candidate that + // supports all of our codepoints. + return idx; + } + } + + return null; + } }; /// Returns a style that when compared must be identical for a run to From 9162e71bccba98a813ebd08c9a1cf0f8f649e3f9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 18 Nov 2025 15:05:39 -1000 Subject: [PATCH 061/209] terminal: render state contains cursor state --- src/terminal/Screen.zig | 2 +- src/terminal/render.zig | 49 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 491d576ea..ba2af2473 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -88,7 +88,7 @@ pub const Dirty = packed struct { /// The cursor position and style. pub const Cursor = struct { - // The x/y position within the viewport. + // The x/y position within the active area. x: size.CellCountInt = 0, y: size.CellCountInt = 0, diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 30f9c6c62..d105f21af 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -3,6 +3,7 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const fastmem = @import("../fastmem.zig"); +const point = @import("point.zig"); const size = @import("size.zig"); const page = @import("page.zig"); const Screen = @import("Screen.zig"); @@ -10,6 +11,9 @@ const ScreenSet = @import("ScreenSet.zig"); const Style = @import("style.zig").Style; const Terminal = @import("Terminal.zig"); +// TODO: +// - tests for cursor state + // Developer note: this is in src/terminal and not src/renderer because // the goal is that this remains generic to multiple renderers. This can // aid specifically with libghostty-vt with converting terminal state to @@ -43,6 +47,9 @@ pub const RenderState = struct { /// area and scrolling with new output. viewport_is_bottom: bool, + /// Cursor state within the viewport. + cursor: Cursor, + /// The rows (y=0 is top) of the viewport. Guaranteed to be `rows` length. /// /// This is a MultiArrayList because only the update cares about @@ -66,11 +73,33 @@ pub const RenderState = struct { .rows = 0, .cols = 0, .viewport_is_bottom = false, + .cursor = .{ + .active = .{ .x = 0, .y = 0 }, + .viewport = null, + .cell = .{}, + .style = undefined, + }, .row_data = .empty, .redraw = false, .screen = .primary, }; + pub const Cursor = struct { + /// The x/y position of the cursor within the active area. + active: point.Coordinate, + + /// The x/y position of the cursor within the viewport. This + /// may be null if the cursor is not visible within the viewport. + viewport: ?point.Coordinate, + + /// The cell data for the cursor position. Managed memory is not + /// safe to access from this. + cell: page.Cell, + + /// The style, always valid even if the cell is default style. + style: Style, + }; + /// A row within the viewport. pub const Row = struct { /// Arena used for any heap allocations for cell contents @@ -174,6 +203,14 @@ pub const RenderState = struct { self.rows = s.pages.rows; self.cols = s.pages.cols; self.viewport_is_bottom = s.viewportIsBottom(); + self.cursor.active = .{ .x = s.cursor.x, .y = s.cursor.y }; + self.cursor.cell = s.cursor.page_cell.*; + self.cursor.style = s.cursor.page_pin.style(s.cursor.page_cell); + + // Always reset the cursor viewport position. In the future we can + // probably cache this by comparing the cursor pin and viewport pin + // but may not be worth it. + self.cursor.viewport = null; // Ensure our row length is exactly our height, freeing or allocating // data as necessary. In most cases we'll have a perfectly matching @@ -226,6 +263,18 @@ pub const RenderState = struct { ); var y: size.CellCountInt = 0; while (row_it.next()) |row_pin| : (y = y + 1) { + // Find our cursor if we haven't found it yet. We do this even + // if the row is not dirty because the cursor is unrelated. + if (self.cursor.viewport == null and + row_pin.node == s.cursor.page_pin.node and + row_pin.y == s.cursor.page_pin.y) + { + self.cursor.viewport = .{ + .y = y, + .x = s.cursor.x, + }; + } + // If the row isn't dirty then we assume it is unchanged. if (!redraw and !row_pin.isDirty()) continue; From ebc8bff8f1f74abaef47fa86d3f5348c7d15fe91 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 19 Nov 2025 05:07:07 -1000 Subject: [PATCH 062/209] renderer: switch to using render state --- src/renderer/cell.zig | 200 ++++++++++------ src/renderer/generic.zig | 476 ++++++++++++++------------------------- src/terminal/render.zig | 43 +++- 3 files changed, 334 insertions(+), 385 deletions(-) diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index 855abdf76..9e5802ea5 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -255,8 +255,12 @@ pub fn isSymbol(cp: u21) bool { /// Returns the appropriate `constraint_width` for /// the provided cell when rendering its glyph(s). -pub fn constraintWidth(cell_pin: terminal.Pin) u2 { - const cell = cell_pin.rowAndCell().cell; +pub fn constraintWidth( + raw_slice: []const terminal.page.Cell, + x: usize, + cols: usize, +) u2 { + const cell = raw_slice[x]; const cp = cell.codepoint(); const grid_width = cell.gridWidth(); @@ -271,20 +275,14 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 { if (!isSymbol(cp)) return grid_width; // If we are at the end of the screen it must be constrained to one cell. - if (cell_pin.x == cell_pin.node.data.size.cols - 1) return 1; + if (x == cols - 1) return 1; // If we have a previous cell and it was a symbol then we need // to also constrain. This is so that multiple PUA glyphs align. // This does not apply if the previous symbol is a graphics // element such as a block element or Powerline glyph. - if (cell_pin.x > 0) { - const prev_cp = prev_cp: { - var copy = cell_pin; - copy.x -= 1; - const prev_cell = copy.rowAndCell().cell; - break :prev_cp prev_cell.codepoint(); - }; - + if (x > 0) { + const prev_cp = raw_slice[x - 1].codepoint(); if (isSymbol(prev_cp) and !isGraphicsElement(prev_cp)) { return 1; } @@ -292,15 +290,8 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 { // If the next cell is whitespace, then we // allow the glyph to be up to two cells wide. - const next_cp = next_cp: { - var copy = cell_pin; - copy.x += 1; - const next_cell = copy.rowAndCell().cell; - break :next_cp next_cell.codepoint(); - }; - if (next_cp == 0 or isSpace(next_cp)) { - return 2; - } + const next_cp = raw_slice[x + 1].codepoint(); + if (next_cp == 0 or isSpace(next_cp)) return 2; // Otherwise, this has to be 1 cell wide. return 1; @@ -524,108 +515,171 @@ test "Cell constraint widths" { const testing = std.testing; const alloc = testing.allocator; - var s = try terminal.Screen.init(alloc, .{ .cols = 4, .rows = 1, .max_scrollback = 0 }); + var t: terminal.Terminal = try .init(alloc, .{ + .cols = 4, + .rows = 1, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); defer s.deinit(); + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + // for each case, the numbers in the comment denote expected // constraint widths for the symbol-containing cells // symbol->nothing: 2 { - try s.testWriteString(""); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(2, constraintWidth(p0)); - s.reset(); + t.fullReset(); + try s.nextSlice(""); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); } // symbol->character: 1 { - try s.testWriteString("z"); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(1, constraintWidth(p0)); - s.reset(); + t.fullReset(); + try s.nextSlice("z"); + try state.update(alloc, &t); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); } // symbol->space: 2 { - try s.testWriteString(" z"); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(2, constraintWidth(p0)); - s.reset(); + t.fullReset(); + try s.nextSlice(" z"); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); } // symbol->no-break space: 1 { - try s.testWriteString("\u{00a0}z"); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(1, constraintWidth(p0)); - s.reset(); + t.fullReset(); + try s.nextSlice("\u{00a0}z"); + try state.update(alloc, &t); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); } // symbol->end of row: 1 { - try s.testWriteString(" "); - const p3 = s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?; - try testing.expectEqual(1, constraintWidth(p3)); - s.reset(); + t.fullReset(); + try s.nextSlice(" "); + try state.update(alloc, &t); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 3, + state.cols, + )); } // character->symbol: 2 { - try s.testWriteString("z"); - const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?; - try testing.expectEqual(2, constraintWidth(p1)); - s.reset(); + t.fullReset(); + try s.nextSlice("z"); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 1, + state.cols, + )); } // symbol->symbol: 1,1 { - try s.testWriteString(""); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?; - try testing.expectEqual(1, constraintWidth(p0)); - try testing.expectEqual(1, constraintWidth(p1)); - s.reset(); + t.fullReset(); + try s.nextSlice(""); + try state.update(alloc, &t); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 1, + state.cols, + )); } // symbol->space->symbol: 2,2 { - try s.testWriteString(" "); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const p2 = s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?; - try testing.expectEqual(2, constraintWidth(p0)); - try testing.expectEqual(2, constraintWidth(p2)); - s.reset(); + t.fullReset(); + try s.nextSlice(" "); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 2, + state.cols, + )); } // symbol->powerline: 1 (dedicated test because powerline is special-cased in cellpkg) { - try s.testWriteString(""); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(1, constraintWidth(p0)); - s.reset(); + t.fullReset(); + try s.nextSlice(""); + try state.update(alloc, &t); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); } // powerline->symbol: 2 (dedicated test because powerline is special-cased in cellpkg) { - try s.testWriteString(""); - const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?; - try testing.expectEqual(2, constraintWidth(p1)); - s.reset(); + t.fullReset(); + try s.nextSlice(""); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 1, + state.cols, + )); } // powerline->nothing: 2 (dedicated test because powerline is special-cased in cellpkg) { - try s.testWriteString(""); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(2, constraintWidth(p0)); - s.reset(); + t.fullReset(); + try s.nextSlice(""); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); } // powerline->space: 2 (dedicated test because powerline is special-cased in cellpkg) { - try s.testWriteString(" z"); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(2, constraintWidth(p0)); - s.reset(); + t.fullReset(); + try s.nextSlice(" z"); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); } } diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 48c6da54f..77d826ed2 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -125,12 +125,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// cells goes into a separate shader. cells: cellpkg.Contents, - /// The last viewport that we based our rebuild off of. If this changes, - /// then we do a full rebuild of the cells. The pointer values in this pin - /// are NOT SAFE to read because they may be modified, freed, etc from the - /// termio thread. We treat the pointers as integers for comparison only. - cells_viewport: ?terminal.Pin = null, - /// Set to true after rebuildCells is called. This can be used /// to determine if any possible changes have been made to the /// cells for the draw call. @@ -940,8 +934,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } /// Mark the full screen as dirty so that we redraw everything. - pub fn markDirty(self: *Self) void { - self.cells_viewport = null; + pub inline fn markDirty(self: *Self) void { + self.terminal_state.redraw = true; } /// Called when we get an updated display ID for our display link. @@ -1047,7 +1041,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Force a full rebuild, because cached rows may still reference // an outdated atlas from the old grid and this can cause garbage // to be rendered. - self.cells_viewport = null; + self.markDirty(); } /// Update uniforms that are based on the font grid. @@ -1070,17 +1064,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const Critical = struct { bg: terminal.color.RGB, fg: terminal.color.RGB, - screen: terminal.Screen, - screen_type: terminal.ScreenSet.Key, mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, cursor_color: ?terminal.color.RGB, cursor_style: ?renderer.CursorStyle, color_palette: terminal.color.Palette, scrollbar: terminal.Scrollbar, - - /// If true, rebuild the full screen. - full_rebuild: bool, }; // Update all our data as tightly as possible within the mutex. @@ -1122,19 +1111,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .{ bg, fg }; }; - // Get the viewport pin so that we can compare it to the current. - const viewport_pin = state.terminal.screens.active.pages.pin(.{ .viewport = .{} }).?; - - // We used to share terminal state, but we've since learned through - // analysis that it is faster to copy the terminal state than to - // hold the lock while rebuilding GPU cells. - var screen_copy = try state.terminal.screens.active.clone( - self.alloc, - .{ .viewport = .{} }, - null, - ); - errdefer screen_copy.deinit(); - // Whether to draw our cursor or not. const cursor_style = if (state.terminal.flags.password_input) .lock @@ -1166,77 +1142,23 @@ pub fn Renderer(comptime GraphicsAPI: type) type { try self.prepKittyGraphics(state.terminal); } - // If we have any terminal dirty flags set then we need to rebuild - // the entire screen. This can be optimized in the future. - const full_rebuild: bool = rebuild: { - { - const Int = @typeInfo(terminal.Terminal.Dirty).@"struct".backing_integer.?; - const v: Int = @bitCast(state.terminal.flags.dirty); - if (v > 0) break :rebuild true; - } - { - const Int = @typeInfo(terminal.Screen.Dirty).@"struct".backing_integer.?; - const v: Int = @bitCast(state.terminal.screens.active.dirty); - if (v > 0) break :rebuild true; - } - - // If our viewport changed then we need to rebuild the entire - // screen because it means we scrolled. If we have no previous - // viewport then we must rebuild. - const prev_viewport = self.cells_viewport orelse break :rebuild true; - if (!prev_viewport.eql(viewport_pin)) break :rebuild true; - - break :rebuild false; - }; - - // Reset the dirty flags in the terminal and screen. We assume - // that our rebuild will be successful since so we optimize for - // success and reset while we hold the lock. This is much easier - // than coordinating row by row or as changes are persisted. - state.terminal.flags.dirty = .{}; - state.terminal.screens.active.dirty = .{}; - { - var it = state.terminal.screens.active.pages.pageIterator( - .right_down, - .{ .viewport = .{} }, - null, - ); - while (it.next()) |chunk| { - chunk.node.data.dirty = false; - for (chunk.rows()) |*row| { - row.dirty = false; - } - } - } - - // Update our viewport pin - self.cells_viewport = viewport_pin; - break :critical .{ .bg = bg, .fg = fg, - .screen = screen_copy, - .screen_type = state.terminal.screens.active_key, .mouse = state.mouse, .preedit = preedit, .cursor_color = state.terminal.colors.cursor.get(), .cursor_style = cursor_style, .color_palette = state.terminal.colors.palette.current, .scrollbar = scrollbar, - .full_rebuild = full_rebuild, }; }; defer { - critical.screen.deinit(); if (critical.preedit) |p| p.deinit(self.alloc); } // Build our GPU cells try self.rebuildCells( - critical.full_rebuild, - &critical.screen, - critical.screen_type, - critical.mouse, critical.preedit, critical.cursor_style, &critical.color_palette, @@ -2098,7 +2020,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (bg_image_config_changed) self.updateBgImageBuffer(); // Reset our viewport to force a rebuild, in case of a font change. - self.cells_viewport = null; + self.markDirty(); const blending_changed = old_blending != config.blending; @@ -2319,95 +2241,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } - fn rebuildCells2( - self: *Self, - ) !void { - const state: *terminal.RenderState = &self.terminal_state; - - self.draw_mutex.lock(); - defer self.draw_mutex.unlock(); - - // Handle the case that our grid size doesn't match the terminal - // state grid size. It's possible our backing views for renderers - // have a mismatch temporarily since view resize is handled async - // to terminal state resize and is mostly dependent on GUI - // frameworks. - const grid_size_diff = - self.cells.size.rows != state.rows or - self.cells.size.columns != state.cols; - if (grid_size_diff) { - var new_size = self.cells.size; - new_size.rows = state.rows; - new_size.columns = state.cols; - try self.cells.resize(self.alloc, new_size); - - // Update our uniforms accordingly, otherwise - // our background cells will be out of place. - self.uniforms.grid_size = .{ new_size.columns, new_size.rows }; - } - - // Redraw means we are redrawing the full grid, regardless of - // individual row dirtiness. - const redraw = state.redraw or grid_size_diff; - - if (redraw) { - // If we are doing a full rebuild, then we clear the entire - // cell buffer. - self.cells.reset(); - - // We also reset our padding extension depending on the - // screen type - switch (self.config.padding_color) { - .background => {}, - - // For extension, assume we are extending in all directions. - // For "extend" this may be disabled due to heuristics below. - .extend, .@"extend-always" => { - self.uniforms.padding_extend = .{ - .up = true, - .down = true, - .left = true, - .right = true, - }; - }, - } - } - - // Go through all the rows and rebuild as necessary. If we have - // a size mismatch on the state and our grid we just fill what - // we can from the BOTTOM of the viewport. - const start_idx = state.rows - @min( - state.rows, - self.cells.size.rows, - ); - const row_data = state.row_data.slice(); - for ( - 0.., - row_data.items(.cells)[start_idx..], - row_data.items(.dirty)[start_idx..], - ) |y, *cell, dirty| { - if (!redraw) { - // Only rebuild if we are doing a full rebuild or - // this row is dirty. - if (!dirty) continue; - - // Clear the cells if the row is dirty - self.cells.clear(y); - } - - _ = cell; - } - } - /// Convert the terminal state to GPU cells stored in CPU memory. These /// are then synced to the GPU in the next frame. This only updates CPU /// memory and doesn't touch the GPU. fn rebuildCells( self: *Self, - wants_rebuild: bool, - screen: *terminal.Screen, - screen_type: terminal.ScreenSet.Key, - mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, cursor_style_: ?renderer.CursorStyle, color_palette: *const terminal.color.Palette, @@ -2415,6 +2253,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { foreground: terminal.color.RGB, terminal_cursor_color: ?terminal.color.RGB, ) !void { + const state: *terminal.RenderState = &self.terminal_state; + defer state.redraw = false; + self.draw_mutex.lock(); defer self.draw_mutex.unlock(); @@ -2426,20 +2267,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // std.log.warn("[rebuildCells time] {}\t{}", .{start_micro, end.since(start) / std.time.ns_per_us}); // } - _ = screen_type; // we might use this again later so not deleting it yet - - // Create an arena for all our temporary allocations while rebuilding - var arena = ArenaAllocator.init(self.alloc); - defer arena.deinit(); - const arena_alloc = arena.allocator(); - + // TODO: renderstate // Create our match set for the links. - var link_match_set: link.MatchSet = if (mouse.point) |mouse_pt| try self.config.links.matchSet( - arena_alloc, - screen, - mouse_pt, - mouse.mods, - ) else .{}; + // var link_match_set: link.MatchSet = if (mouse.point) |mouse_pt| try self.config.links.matchSet( + // arena_alloc, + // screen, + // mouse_pt, + // mouse.mods, + // ) else .{}; // Determine our x/y range for preedit. We don't want to render anything // here because we will render the preedit separately. @@ -2448,22 +2283,31 @@ pub fn Renderer(comptime GraphicsAPI: type) type { x: [2]terminal.size.CellCountInt, cp_offset: usize, } = if (preedit) |preedit_v| preedit: { - const range = preedit_v.range(screen.cursor.x, screen.pages.cols - 1); + // We base the preedit on the position of the cursor in the + // viewport. If the cursor isn't visible in the viewport we + // don't show it. + const cursor_vp = state.cursor.viewport orelse + break :preedit null; + + const range = preedit_v.range( + cursor_vp.x, + state.cols - 1, + ); break :preedit .{ - .y = screen.cursor.y, + .y = @intCast(cursor_vp.y), .x = .{ range.start, range.end }, .cp_offset = range.cp_offset, }; } else null; const grid_size_diff = - self.cells.size.rows != screen.pages.rows or - self.cells.size.columns != screen.pages.cols; + self.cells.size.rows != state.rows or + self.cells.size.columns != state.cols; if (grid_size_diff) { var new_size = self.cells.size; - new_size.rows = screen.pages.rows; - new_size.columns = screen.pages.cols; + new_size.rows = state.rows; + new_size.columns = state.cols; try self.cells.resize(self.alloc, new_size); // Update our uniforms accordingly, otherwise @@ -2471,8 +2315,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.uniforms.grid_size = .{ new_size.columns, new_size.rows }; } - const rebuild = wants_rebuild or grid_size_diff; - + const rebuild = state.redraw or grid_size_diff; if (rebuild) { // If we are doing a full rebuild, then we clear the entire cell buffer. self.cells.reset(); @@ -2494,76 +2337,82 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } - // We rebuild the cells row-by-row because we - // do font shaping and dirty tracking by row. - var row_it = screen.pages.rowIterator(.left_up, .{ .viewport = .{} }, null); + // Get our row data from our state + const row_data = state.row_data.slice(); + const row_cells = row_data.items(.cells); + const row_dirty = row_data.items(.dirty); + const row_selection = row_data.items(.selection); + // If our cell contents buffer is shorter than the screen viewport, // we render the rows that fit, starting from the bottom. If instead // the viewport is shorter than the cell contents buffer, we align // the top of the viewport with the top of the contents buffer. - var y: terminal.size.CellCountInt = @min( - screen.pages.rows, + const row_len: usize = @min( + state.rows, self.cells.size.rows, ); - while (row_it.next()) |row| { - // The viewport may have more rows than our cell contents, - // so we need to break from the loop early if we hit y = 0. - if (y == 0) break; - - y -= 1; + for ( + 0.., + row_cells[0..row_len], + row_dirty[0..row_len], + row_selection[0..row_len], + ) |y_usize, *cells, *dirty, selection| { + const y: terminal.size.CellCountInt = @intCast(y_usize); if (!rebuild) { // Only rebuild if we are doing a full rebuild or this row is dirty. - if (!row.isDirty()) continue; + if (!dirty.*) continue; // Clear the cells if the row is dirty self.cells.clear(y); } - // True if we want to do font shaping around the cursor. - // We want to do font shaping as long as the cursor is enabled. - const shape_cursor = screen.viewportIsBottom() and - y == screen.cursor.y; - - // We need to get this row's selection, if - // there is one, for proper run splitting. - const row_selection = sel: { - const sel = screen.selection orelse break :sel null; - const pin = screen.pages.pin(.{ .viewport = .{ .y = y } }) orelse - break :sel null; - break :sel sel.containedRow(screen, pin) orelse null; - }; + // Unmark the dirty state in our render state. + dirty.* = false; + // TODO: renderstate // On primary screen, we still apply vertical padding // extension under certain conditions we feel are safe. // // This helps make some scenarios look better while // avoiding scenarios we know do NOT look good. - switch (self.config.padding_color) { - // These already have the correct values set above. - .background, .@"extend-always" => {}, - - // Apply heuristics for padding extension. - .extend => if (y == 0) { - self.uniforms.padding_extend.up = !row.neverExtendBg( - color_palette, - background, - ); - } else if (y == self.cells.size.rows - 1) { - self.uniforms.padding_extend.down = !row.neverExtendBg( - color_palette, - background, - ); - }, - } + // switch (self.config.padding_color) { + // // These already have the correct values set above. + // .background, .@"extend-always" => {}, + // + // // Apply heuristics for padding extension. + // .extend => if (y == 0) { + // self.uniforms.padding_extend.up = !row.neverExtendBg( + // color_palette, + // background, + // ); + // } else if (y == self.cells.size.rows - 1) { + // self.uniforms.padding_extend.down = !row.neverExtendBg( + // color_palette, + // background, + // ); + // }, + // } // Iterator of runs for shaping. + const cells_slice = cells.slice(); var run_iter_opts: font.shape.RunOptions = .{ .grid = self.font_grid, - .screen = screen, - .row = row, - .selection = row_selection, - .cursor_x = if (shape_cursor) screen.cursor.x else null, + .cells = cells_slice, + .selection2 = if (selection) |s| s else null, + + // We want to do font shaping as long as the cursor is + // visible on this viewport. + .cursor_x = cursor_x: { + const vp = state.cursor.viewport orelse break :cursor_x null; + if (vp.y != y) break :cursor_x null; + break :cursor_x vp.x; + }, + + // Old stuff + .screen = undefined, + .row = undefined, + .selection = null, }; run_iter_opts.applyBreakConfig(self.config.font_shaping_break); var run_iter = self.font_shaper.runIterator(run_iter_opts); @@ -2571,13 +2420,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type { var shaper_cells: ?[]const font.shape.Cell = null; var shaper_cells_i: usize = 0; - const row_cells_all = row.cells(.all); - // If our viewport is wider than our cell contents buffer, // we still only process cells up to the width of the buffer. - const row_cells = row_cells_all[0..@min(row_cells_all.len, self.cells.size.columns)]; - - for (row_cells, 0..) |*cell, x| { + const cells_len = @min(cells_slice.len, self.cells.size.columns); + const cells_raw = cells_slice.items(.raw); + const cells_style = cells_slice.items(.style); + for ( + 0.., + cells_raw[0..cells_len], + cells_style[0..cells_len], + ) |x, *cell, *managed_style| { // If this cell falls within our preedit range then we // skip this because preedits are setup separately. if (preedit_range) |range| preedit: { @@ -2610,7 +2462,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.font_shaper_cache.get(run) orelse cache: { // Otherwise we have to shape them. - const cells = try self.font_shaper.shape(run); + const new_cells = try self.font_shaper.shape(run); // Try to cache them. If caching fails for any reason we // continue because it is just a performance optimization, @@ -2618,7 +2470,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.font_shaper_cache.put( self.alloc, run, - cells, + new_cells, ) catch |err| { log.warn( "error caching font shaping results err={}", @@ -2629,49 +2481,42 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // The cells we get from direct shaping are always owned // by the shaper and valid until the next shaping call so // we can safely use them. - break :cache cells; + break :cache new_cells; }; - const cells = shaper_cells.?; - // Advance our index until we reach or pass // our current x position in the shaper cells. - while (run.offset + cells[shaper_cells_i].x < x) { + const shaper_cells_unwrapped = shaper_cells.?; + while (run.offset + shaper_cells_unwrapped[shaper_cells_i].x < x) { shaper_cells_i += 1; } } const wide = cell.wide; - - const style = row.style(cell); - - const cell_pin: terminal.Pin = cell: { - var copy = row; - copy.x = @intCast(x); - break :cell copy; - }; + const style: terminal.Style = if (cell.hasStyling()) + managed_style.* + else + .{}; // True if this cell is selected - const selected: bool = if (screen.selection) |sel| - sel.contains(screen, .{ - .node = row.node, - .y = row.y, - .x = @intCast( - // Spacer tails should show the selection - // state of the wide cell they belong to. - if (wide == .spacer_tail) - x -| 1 - else - x, - ), - }) - else - false; + const selected: bool = selected: { + const sel = selection orelse break :selected false; + const x_compare = if (wide == .spacer_tail) + x -| 1 + else + x; + + break :selected x_compare >= sel[0] and + x_compare <= sel[1]; + }; // The `_style` suffixed values are the colors based on // the cell style (SGR), before applying any additional // configuration, inversions, selections, etc. - const bg_style = style.bg(cell, color_palette); + const bg_style = style.bg( + cell, + color_palette, + ); const fg_style = style.fg(.{ .default = foreground, .palette = color_palette, @@ -2793,16 +2638,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type { continue; } + // TODO: renderstate // Give links a single underline, unless they already have // an underline, in which case use a double underline to // distinguish them. - const underline: terminal.Attribute.Underline = if (link_match_set.contains(screen, cell_pin)) - if (style.flags.underline == .single) - .double - else - .single - else - style.flags.underline; + // const underline: terminal.Attribute.Underline = if (link_match_set.contains(screen, cell_pin)) + // if (style.flags.underline == .single) + // .double + // else + // .single + // else + // style.flags.underline; + const underline = style.flags.underline; // We draw underlines first so that they layer underneath text. // This improves readability when a colored underline is used @@ -2842,7 +2689,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.font_shaper_cache.get(run) orelse cache: { // Otherwise we have to shape them. - const cells = try self.font_shaper.shape(run); + const new_cells = try self.font_shaper.shape(run); // Try to cache them. If caching fails for any reason we // continue because it is just a performance optimization, @@ -2850,7 +2697,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.font_shaper_cache.put( self.alloc, run, - cells, + new_cells, ) catch |err| { log.warn( "error caching font shaping results err={}", @@ -2861,32 +2708,34 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // The cells we get from direct shaping are always owned // by the shaper and valid until the next shaping call so // we can safely use them. - break :cache cells; + break :cache new_cells; }; - const cells = shaper_cells orelse break :glyphs; + const shaped_cells = shaper_cells orelse break :glyphs; // If there are no shaper cells for this run, ignore it. // This can occur for runs of empty cells, and is fine. - if (cells.len == 0) break :glyphs; + if (shaped_cells.len == 0) break :glyphs; // If we encounter a shaper cell to the left of the current // cell then we have some problems. This logic relies on x // position monotonically increasing. - assert(run.offset + cells[shaper_cells_i].x >= x); + assert(run.offset + shaped_cells[shaper_cells_i].x >= x); // NOTE: An assumption is made here that a single cell will never // be present in more than one shaper run. If that assumption is // violated, this logic breaks. - while (shaper_cells_i < cells.len and run.offset + cells[shaper_cells_i].x == x) : ({ + while (shaper_cells_i < shaped_cells.len and + run.offset + shaped_cells[shaper_cells_i].x == x) : ({ shaper_cells_i += 1; }) { self.addGlyph( @intCast(x), @intCast(y), - cell_pin, - cells[shaper_cells_i], + state.cols, + cells_raw, + shaped_cells[shaper_cells_i], shaper_run.?, fg, alpha, @@ -2938,14 +2787,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { inline .@"cell-foreground", .@"cell-background", => |_, tag| { - const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); + const sty: terminal.Style = state.cursor.style; const fg_style = sty.fg(.{ .default = foreground, .palette = color_palette, .bold = self.config.bold_color, }); const bg_style = sty.bg( - screen.cursor.page_cell, + &state.cursor.cell, color_palette, ) orelse background; @@ -2960,21 +2809,27 @@ pub fn Renderer(comptime GraphicsAPI: type) type { break :cursor_color foreground; }; - self.addCursor(screen, style, cursor_color); + self.addCursor( + &state.cursor, + style, + cursor_color, + ); // If the cursor is visible then we set our uniforms. - if (style == .block and screen.viewportIsBottom()) { - const wide = screen.cursor.page_cell.wide; + if (style == .block) cursor_uniforms: { + const cursor_vp = state.cursor.viewport orelse + break :cursor_uniforms; + const wide = state.cursor.cell.wide; self.uniforms.cursor_pos = .{ // If we are a spacer tail of a wide cell, our cursor needs // to move back one cell. The saturate is to ensure we don't // overflow but this shouldn't happen with well-formed input. switch (wide) { - .narrow, .spacer_head, .wide => screen.cursor.x, - .spacer_tail => screen.cursor.x -| 1, + .narrow, .spacer_head, .wide => cursor_vp.x, + .spacer_tail => cursor_vp.x -| 1, }, - screen.cursor.y, + @intCast(cursor_vp.y), }; self.uniforms.bools.cursor_wide = switch (wide) { @@ -2990,14 +2845,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { break :blk txt.color.toTerminalRGB(); } - const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); + const sty = state.cursor.style; const fg_style = sty.fg(.{ .default = foreground, .palette = color_palette, .bold = self.config.bold_color, }); const bg_style = sty.bg( - screen.cursor.page_cell, + &state.cursor.cell, color_palette, ) orelse background; @@ -3157,15 +3012,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self: *Self, x: terminal.size.CellCountInt, y: terminal.size.CellCountInt, - cell_pin: terminal.Pin, + cols: usize, + cell_raws: []const terminal.page.Cell, shaper_cell: font.shape.Cell, shaper_run: font.shape.TextRun, color: terminal.color.RGB, alpha: u8, ) !void { - const rac = cell_pin.rowAndCell(); - const cell = rac.cell; - + const cell = cell_raws[x]; const cp = cell.codepoint(); // Render @@ -3185,7 +3039,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (cellpkg.isSymbol(cp)) .{ .size = .fit, } else .none, - .constraint_width = constraintWidth(cell_pin), + .constraint_width = constraintWidth( + cell_raws, + x, + cols, + ), }, ); @@ -3214,22 +3072,24 @@ pub fn Renderer(comptime GraphicsAPI: type) type { fn addCursor( self: *Self, - screen: *terminal.Screen, + cursor_state: *const terminal.RenderState.Cursor, cursor_style: renderer.CursorStyle, cursor_color: terminal.color.RGB, ) void { + const cursor_vp = cursor_state.viewport orelse return; + // Add the cursor. We render the cursor over the wide character if // we're on the wide character tail. const wide, const x = cell: { // The cursor goes over the screen cursor position. - const cell = screen.cursor.page_cell; - if (cell.wide != .spacer_tail or screen.cursor.x == 0) - break :cell .{ cell.wide == .wide, screen.cursor.x }; + if (!cursor_vp.wide_tail) break :cell .{ + cursor_state.cell.wide == .wide, + cursor_vp.x, + }; - // If we're part of a wide character, we move the cursor back to - // the actual character. - const prev_cell = screen.cursorCellLeft(1); - break :cell .{ prev_cell.wide == .wide, screen.cursor.x - 1 }; + // If we're part of a wide character, we move the cursor back + // to the actual character. + break :cell .{ true, cursor_vp.x - 1 }; }; const alpha: u8 = if (!self.focused) 255 else alpha: { @@ -3288,7 +3148,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.cells.setCursor(.{ .atlas = .grayscale, .bools = .{ .is_cursor_glyph = true }, - .grid_pos = .{ x, screen.cursor.y }, + .grid_pos = .{ x, cursor_vp.y }, .color = .{ cursor_color.r, cursor_color.g, cursor_color.b, alpha }, .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, .glyph_size = .{ render.glyph.width, render.glyph.height }, diff --git a/src/terminal/render.zig b/src/terminal/render.zig index d105f21af..0033ef16f 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -6,6 +6,7 @@ const fastmem = @import("../fastmem.zig"); const point = @import("point.zig"); const size = @import("size.zig"); const page = @import("page.zig"); +const Pin = @import("PageList.zig").Pin; const Screen = @import("Screen.zig"); const ScreenSet = @import("ScreenSet.zig"); const Style = @import("style.zig").Style; @@ -68,6 +69,11 @@ pub const RenderState = struct { /// to detect changes. screen: ScreenSet.Key, + /// The last viewport pin used to generate this state. This is NOT + /// a tracked pin and is generally NOT safe to read other than the direct + /// values for comparison. + viewport_pin: ?Pin = null, + /// Initial state. pub const empty: RenderState = .{ .rows = 0, @@ -90,7 +96,7 @@ pub const RenderState = struct { /// The x/y position of the cursor within the viewport. This /// may be null if the cursor is not visible within the viewport. - viewport: ?point.Coordinate, + viewport: ?Viewport, /// The cell data for the cursor position. Managed memory is not /// safe to access from this. @@ -98,6 +104,17 @@ pub const RenderState = struct { /// The style, always valid even if the cell is default style. style: Style, + + pub const Viewport = struct { + /// The x/y position of the cursor within the viewport. + x: size.CellCountInt, + y: size.CellCountInt, + + /// Whether the cursor is part of a wide character and + /// on the tail of it. If so, some renderers may use this + /// to move the cursor back one. + wide_tail: bool, + }; }; /// A row within the viewport. @@ -118,7 +135,7 @@ pub const RenderState = struct { dirty: bool, /// The x range of the selection within this row. - selection: [2]size.CellCountInt, + selection: ?[2]size.CellCountInt, }; pub const Cell = struct { @@ -159,6 +176,7 @@ pub const RenderState = struct { t: *Terminal, ) Allocator.Error!void { const s: *Screen = t.screens.active; + const viewport_pin = s.pages.getTopLeft(.viewport); const redraw = redraw: { // If our screen key changed, we need to do a full rebuild // because our render state is viewport-specific. @@ -187,6 +205,11 @@ pub const RenderState = struct { break :redraw true; } + // If our viewport pin changed, we do a full rebuild. + if (self.viewport_pin) |old| { + if (!old.eql(viewport_pin)) break :redraw true; + } + break :redraw false; }; @@ -203,6 +226,7 @@ pub const RenderState = struct { self.rows = s.pages.rows; self.cols = s.pages.cols; self.viewport_is_bottom = s.viewportIsBottom(); + self.viewport_pin = viewport_pin; self.cursor.active = .{ .x = s.cursor.x, .y = s.cursor.y }; self.cursor.cell = s.cursor.page_cell.*; self.cursor.style = s.cursor.page_pin.style(s.cursor.page_cell); @@ -232,7 +256,7 @@ pub const RenderState = struct { .arena = .{}, .cells = .empty, .dirty = true, - .selection = .{ 0, 0 }, + .selection = null, }); } } else { @@ -272,11 +296,22 @@ pub const RenderState = struct { self.cursor.viewport = .{ .y = y, .x = s.cursor.x, + + // Future: we should use our own state here to look this + // up rather than calling this. + .wide_tail = if (s.cursor.x > 0) + s.cursorCellLeft(1).wide == .wide + else + false, }; } // If the row isn't dirty then we assume it is unchanged. - if (!redraw and !row_pin.isDirty()) continue; + var dirty_set = row_pin.node.data.dirtyBitSet(); + if (!redraw and !dirty_set.isSet(row_pin.y)) continue; + + // Clear the dirty flag on the row + dirty_set.unset(row_pin.y); // Promote our arena. State is copied by value so we need to // restore it on all exit paths so we don't leak memory. From d1e87c73fbe0317cb6f71777630279e58e31548a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 19 Nov 2025 06:36:33 -1000 Subject: [PATCH 063/209] terminal: renderstate now has terminal colors --- src/renderer/generic.zig | 77 +++++++++++++--------------------------- src/terminal/render.zig | 34 ++++++++++++++++++ 2 files changed, 59 insertions(+), 52 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 77d826ed2..8c914e476 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1062,18 +1062,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { ) !void { // Data we extract out of the critical area. const Critical = struct { - bg: terminal.color.RGB, - fg: terminal.color.RGB, mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, - cursor_color: ?terminal.color.RGB, cursor_style: ?renderer.CursorStyle, - color_palette: terminal.color.Palette, scrollbar: terminal.Scrollbar, }; // Update all our data as tightly as possible within the mutex. - var critical: Critical = critical: { + const critical: Critical = critical: { // const start = try std.time.Instant.now(); // const start_micro = std.time.microTimestamp(); // defer { @@ -1100,17 +1096,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // cross-thread mailbox message within the IO path. const scrollbar = state.terminal.screens.active.pages.scrollbar(); - // Get our bg/fg, swap them if reversed. - const RGB = terminal.color.RGB; - const bg: RGB, const fg: RGB = colors: { - const bg = state.terminal.colors.background.get().?; - const fg = state.terminal.colors.foreground.get().?; - break :colors if (state.terminal.modes.get(.reverse_colors)) - .{ fg, bg } - else - .{ bg, fg }; - }; - // Whether to draw our cursor or not. const cursor_style = if (state.terminal.flags.password_input) .lock @@ -1143,13 +1128,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } break :critical .{ - .bg = bg, - .fg = fg, .mouse = state.mouse, .preedit = preedit, - .cursor_color = state.terminal.colors.cursor.get(), .cursor_style = cursor_style, - .color_palette = state.terminal.colors.palette.current, .scrollbar = scrollbar, }; }; @@ -1161,10 +1142,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { try self.rebuildCells( critical.preedit, critical.cursor_style, - &critical.color_palette, - critical.bg, - critical.fg, - critical.cursor_color, ); // Notify our shaper we're done for the frame. For some shapers, @@ -1186,9 +1163,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Update our background color self.uniforms.bg_color = .{ - critical.bg.r, - critical.bg.g, - critical.bg.b, + self.terminal_state.colors.background.r, + self.terminal_state.colors.background.g, + self.terminal_state.colors.background.b, @intFromFloat(@round(self.config.background_opacity * 255.0)), }; } @@ -2248,10 +2225,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self: *Self, preedit: ?renderer.State.Preedit, cursor_style_: ?renderer.CursorStyle, - color_palette: *const terminal.color.Palette, - background: terminal.color.RGB, - foreground: terminal.color.RGB, - terminal_cursor_color: ?terminal.color.RGB, ) !void { const state: *terminal.RenderState = &self.terminal_state; defer state.redraw = false; @@ -2515,11 +2488,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // configuration, inversions, selections, etc. const bg_style = style.bg( cell, - color_palette, + &state.colors.palette, ); const fg_style = style.fg(.{ - .default = foreground, - .palette = color_palette, + .default = state.colors.foreground, + .palette = &state.colors.palette, .bold = self.config.bold_color, }); @@ -2538,7 +2511,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // If no configuration, then our selection background // is our foreground color. - break :bg foreground; + break :bg state.colors.foreground; } // Not selected @@ -2560,7 +2533,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const fg = fg: { // Our happy-path non-selection background color // is our style or our configured defaults. - const final_bg = bg_style orelse background; + const final_bg = bg_style orelse state.colors.background; // Whether we need to use the bg color as our fg color: // - Cell is selected, inverted, and set to cell-foreground @@ -2576,7 +2549,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }; } - break :fg background; + break :fg state.colors.background; } break :fg if (style.flags.inverse) @@ -2590,7 +2563,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Set the cell's background color. { - const rgb = bg orelse background; + const rgb = bg orelse state.colors.background; // Determine our background alpha. If we have transparency configured // then this is dynamic depending on some situations. This is all @@ -2658,7 +2631,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { @intCast(x), @intCast(y), underline, - style.underlineColor(color_palette) orelse fg, + style.underlineColor(&state.colors.palette) orelse fg, alpha, ) catch |err| { log.warn( @@ -2779,7 +2752,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const style = cursor_style_ orelse break :cursor; const cursor_color = cursor_color: { // If an explicit cursor color was set by OSC 12, use that. - if (terminal_cursor_color) |v| break :cursor_color v; + if (state.colors.cursor) |v| break :cursor_color v; // Use our configured color if specified if (self.config.cursor_color) |v| switch (v) { @@ -2789,14 +2762,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { => |_, tag| { const sty: terminal.Style = state.cursor.style; const fg_style = sty.fg(.{ - .default = foreground, - .palette = color_palette, + .default = state.colors.foreground, + .palette = &state.colors.palette, .bold = self.config.bold_color, }); const bg_style = sty.bg( &state.cursor.cell, - color_palette, - ) orelse background; + &state.colors.palette, + ) orelse state.colors.background; break :cursor_color switch (tag) { .color => unreachable, @@ -2806,7 +2779,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }, }; - break :cursor_color foreground; + break :cursor_color state.colors.foreground; }; self.addCursor( @@ -2847,14 +2820,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const sty = state.cursor.style; const fg_style = sty.fg(.{ - .default = foreground, - .palette = color_palette, + .default = state.colors.foreground, + .palette = &state.colors.palette, .bold = self.config.bold_color, }); const bg_style = sty.bg( &state.cursor.cell, - color_palette, - ) orelse background; + &state.colors.palette, + ) orelse state.colors.background; break :blk switch (txt) { // If the cell is reversed, use the opposite cell color instead. @@ -2862,7 +2835,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .@"cell-background" => if (sty.flags.inverse) fg_style else bg_style, else => unreachable, }; - } else background; + } else state.colors.background; self.uniforms.cursor_color = .{ uniform_color.r, @@ -2881,8 +2854,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.addPreeditCell( cp, .{ .x = x, .y = range.y }, - background, - foreground, + state.colors.background, + state.colors.foreground, ) catch |err| { log.warn("error building preedit cell, will be invalid x={} y={}, err={}", .{ x, diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 0033ef16f..6d0820848 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -3,6 +3,7 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const fastmem = @import("../fastmem.zig"); +const color = @import("color.zig"); const point = @import("point.zig"); const size = @import("size.zig"); const page = @import("page.zig"); @@ -14,6 +15,8 @@ const Terminal = @import("Terminal.zig"); // TODO: // - tests for cursor state +// - tests for dirty state +// - tests for colors // Developer note: this is in src/terminal and not src/renderer because // the goal is that this remains generic to multiple renderers. This can @@ -48,6 +51,9 @@ pub const RenderState = struct { /// area and scrolling with new output. viewport_is_bottom: bool, + /// The color state for the terminal. + colors: Colors, + /// Cursor state within the viewport. cursor: Cursor, @@ -79,6 +85,12 @@ pub const RenderState = struct { .rows = 0, .cols = 0, .viewport_is_bottom = false, + .colors = .{ + .background = .{}, + .foreground = .{}, + .cursor = null, + .palette = color.default, + }, .cursor = .{ .active = .{ .x = 0, .y = 0 }, .viewport = null, @@ -90,6 +102,17 @@ pub const RenderState = struct { .screen = .primary, }; + /// The color state for the terminal. + /// + /// The background/foreground will be reversed if the terminal reverse + /// color mode is on! You do not need to handle that manually! + pub const Colors = struct { + background: color.RGB, + foreground: color.RGB, + cursor: ?color.RGB, + palette: color.Palette, + }; + pub const Cursor = struct { /// The x/y position of the cursor within the active area. active: point.Coordinate, @@ -236,6 +259,17 @@ pub const RenderState = struct { // but may not be worth it. self.cursor.viewport = null; + // Colors. + self.colors.cursor = t.colors.cursor.get(); + self.colors.palette = t.colors.palette.current; + if (t.modes.get(.reverse_colors)) { + self.colors.background = t.colors.foreground.get().?; + self.colors.foreground = t.colors.background.get().?; + } else { + self.colors.background = t.colors.background.get().?; + self.colors.foreground = t.colors.foreground.get().?; + } + // Ensure our row length is exactly our height, freeing or allocating // data as necessary. In most cases we'll have a perfectly matching // size. From 07115ce9a9b06f5a6f79f8181c01f06388a3dce8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 19 Nov 2025 06:53:51 -1000 Subject: [PATCH 064/209] terminal: render state contains raw row data --- src/terminal/render.zig | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 6d0820848..b36550650 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -149,6 +149,9 @@ pub const RenderState = struct { /// change often. arena: ArenaAllocator.State, + /// Raw row data. + raw: page.Row, + /// The cells in this row. Guaranteed to be `cols` length. cells: std.MultiArrayList(Cell), @@ -262,12 +265,20 @@ pub const RenderState = struct { // Colors. self.colors.cursor = t.colors.cursor.get(); self.colors.palette = t.colors.palette.current; - if (t.modes.get(.reverse_colors)) { - self.colors.background = t.colors.foreground.get().?; - self.colors.foreground = t.colors.background.get().?; - } else { - self.colors.background = t.colors.background.get().?; - self.colors.foreground = t.colors.foreground.get().?; + bg_fg: { + // Background/foreground can be unset initially which would + // depend on "default" background/foreground. The expected use + // case of Terminal is that the caller set their own configured + // defaults on load so this doesn't happen. + const bg = t.colors.background.get() orelse break :bg_fg; + const fg = t.colors.foreground.get() orelse break :bg_fg; + if (t.modes.get(.reverse_colors)) { + self.colors.background = fg; + self.colors.foreground = bg; + } else { + self.colors.background = bg; + self.colors.foreground = fg; + } } // Ensure our row length is exactly our height, freeing or allocating @@ -288,6 +299,7 @@ pub const RenderState = struct { for (old_len..self.rows) |y| { row_data.set(y, .{ .arena = .{}, + .raw = undefined, .cells = .empty, .dirty = true, .selection = null, @@ -310,6 +322,7 @@ pub const RenderState = struct { // Break down our row data const row_data = self.row_data.slice(); const row_arenas = row_data.items(.arena); + const row_raws = row_data.items(.raw); const row_cells = row_data.items(.cells); const row_dirties = row_data.items(.dirty); @@ -365,6 +378,9 @@ pub const RenderState = struct { const page_cells: []const page.Cell = p.getCells(page_rac.row); assert(page_cells.len == self.cols); + // Copy our raw row data + row_raws[y] = page_rac.row.*; + // Note: our cells MultiArrayList uses our general allocator. // We do this on purpose because as rows become dirty, we do // not want to reallocate space for cells (which are large). This From cc268694ed8c0c72ccbf83d26d4598874de706cf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 19 Nov 2025 06:53:51 -1000 Subject: [PATCH 065/209] renderer: convert bg extend to new render state --- src/renderer/generic.zig | 59 +++++++++++++++++++++--------------- src/renderer/row.zig | 64 +++++++++++++++++++++++++++++++++++++++ src/terminal/PageList.zig | 59 ------------------------------------ 3 files changed, 98 insertions(+), 84 deletions(-) create mode 100644 src/renderer/row.zig diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 8c914e476..48be0d95e 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -15,6 +15,7 @@ const cellpkg = @import("cell.zig"); const noMinContrast = cellpkg.noMinContrast; const constraintWidth = cellpkg.constraintWidth; const isCovering = cellpkg.isCovering; +const rowNeverExtendBg = @import("row.zig").neverExtendBg; const imagepkg = @import("image.zig"); const Image = imagepkg.Image; const ImageMap = imagepkg.ImageMap; @@ -2312,6 +2313,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Get our row data from our state const row_data = state.row_data.slice(); + const row_raws = row_data.items(.raw); const row_cells = row_data.items(.cells); const row_dirty = row_data.items(.dirty); const row_selection = row_data.items(.selection); @@ -2326,10 +2328,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { ); for ( 0.., + row_raws[0..row_len], row_cells[0..row_len], row_dirty[0..row_len], row_selection[0..row_len], - ) |y_usize, *cells, *dirty, selection| { + ) |y_usize, row, *cells, *dirty, selection| { const y: terminal.size.CellCountInt = @intCast(y_usize); if (!rebuild) { @@ -2343,32 +2346,43 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Unmark the dirty state in our render state. dirty.* = false; - // TODO: renderstate + // If our viewport is wider than our cell contents buffer, + // we still only process cells up to the width of the buffer. + const cells_slice = cells.slice(); + const cells_len = @min(cells_slice.len, self.cells.size.columns); + const cells_raw = cells_slice.items(.raw); + const cells_style = cells_slice.items(.style); + // On primary screen, we still apply vertical padding // extension under certain conditions we feel are safe. // // This helps make some scenarios look better while // avoiding scenarios we know do NOT look good. - // switch (self.config.padding_color) { - // // These already have the correct values set above. - // .background, .@"extend-always" => {}, - // - // // Apply heuristics for padding extension. - // .extend => if (y == 0) { - // self.uniforms.padding_extend.up = !row.neverExtendBg( - // color_palette, - // background, - // ); - // } else if (y == self.cells.size.rows - 1) { - // self.uniforms.padding_extend.down = !row.neverExtendBg( - // color_palette, - // background, - // ); - // }, - // } + switch (self.config.padding_color) { + // These already have the correct values set above. + .background, .@"extend-always" => {}, + + // Apply heuristics for padding extension. + .extend => if (y == 0) { + self.uniforms.padding_extend.up = !rowNeverExtendBg( + row, + cells_raw, + cells_style, + &state.colors.palette, + state.colors.background, + ); + } else if (y == self.cells.size.rows - 1) { + self.uniforms.padding_extend.down = !rowNeverExtendBg( + row, + cells_raw, + cells_style, + &state.colors.palette, + state.colors.background, + ); + }, + } // Iterator of runs for shaping. - const cells_slice = cells.slice(); var run_iter_opts: font.shape.RunOptions = .{ .grid = self.font_grid, .cells = cells_slice, @@ -2393,11 +2407,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { var shaper_cells: ?[]const font.shape.Cell = null; var shaper_cells_i: usize = 0; - // If our viewport is wider than our cell contents buffer, - // we still only process cells up to the width of the buffer. - const cells_len = @min(cells_slice.len, self.cells.size.columns); - const cells_raw = cells_slice.items(.raw); - const cells_style = cells_slice.items(.style); for ( 0.., cells_raw[0..cells_len], diff --git a/src/renderer/row.zig b/src/renderer/row.zig new file mode 100644 index 000000000..157d22b54 --- /dev/null +++ b/src/renderer/row.zig @@ -0,0 +1,64 @@ +const std = @import("std"); +const terminal = @import("../terminal/main.zig"); + +// TODO: Test neverExtendBg function + +/// Returns true if the row of this pin should never have its background +/// color extended for filling padding space in the renderer. This is +/// a set of heuristics that help making our padding look better. +pub fn neverExtendBg( + row: terminal.page.Row, + cells: []const terminal.page.Cell, + styles: []const terminal.Style, + palette: *const terminal.color.Palette, + default_background: terminal.color.RGB, +) bool { + // Any semantic prompts should not have their background extended + // because prompts often contain special formatting (such as + // powerline) that looks bad when extended. + switch (row.semantic_prompt) { + .prompt, .prompt_continuation, .input => return true, + .unknown, .command => {}, + } + + for (0.., cells) |x, *cell| { + // If any cell has a default background color then we don't + // extend because the default background color probably looks + // good enough as an extension. + switch (cell.content_tag) { + // If it is a background color cell, we check the color. + .bg_color_palette, .bg_color_rgb => { + const s: terminal.Style = if (cell.hasStyling()) styles[x] else .{}; + const bg = s.bg(cell, palette) orelse return true; + if (bg.eql(default_background)) return true; + }, + + // If its a codepoint cell we can check the style. + .codepoint, .codepoint_grapheme => { + // For codepoint containing, we also never extend bg + // if any cell has a powerline glyph because these are + // perfect-fit. + switch (cell.codepoint()) { + // Powerline + 0xE0B0...0xE0C8, + 0xE0CA, + 0xE0CC...0xE0D2, + 0xE0D4, + => return true, + + else => {}, + } + + // Never extend a cell that has a default background. + // A default background is applied if there is no background + // on the style or the explicitly set background + // matches our default background. + const s: terminal.Style = if (cell.hasStyling()) styles[x] else .{}; + const bg = s.bg(cell, palette) orelse return true; + if (bg.eql(default_background)) return true; + }, + } + } + + return false; +} diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index aab01fa7c..0e793a254 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -3977,65 +3977,6 @@ pub const Pin = struct { self.rowAndCell().row.dirty = true; } - /// Returns true if the row of this pin should never have its background - /// color extended for filling padding space in the renderer. This is - /// a set of heuristics that help making our padding look better. - pub fn neverExtendBg( - self: Pin, - palette: *const color.Palette, - default_background: color.RGB, - ) bool { - // Any semantic prompts should not have their background extended - // because prompts often contain special formatting (such as - // powerline) that looks bad when extended. - const rac = self.rowAndCell(); - switch (rac.row.semantic_prompt) { - .prompt, .prompt_continuation, .input => return true, - .unknown, .command => {}, - } - - for (self.cells(.all)) |*cell| { - // If any cell has a default background color then we don't - // extend because the default background color probably looks - // good enough as an extension. - switch (cell.content_tag) { - // If it is a background color cell, we check the color. - .bg_color_palette, .bg_color_rgb => { - const s = self.style(cell); - const bg = s.bg(cell, palette) orelse return true; - if (bg.eql(default_background)) return true; - }, - - // If its a codepoint cell we can check the style. - .codepoint, .codepoint_grapheme => { - // For codepoint containing, we also never extend bg - // if any cell has a powerline glyph because these are - // perfect-fit. - switch (cell.codepoint()) { - // Powerline - 0xE0B0...0xE0C8, - 0xE0CA, - 0xE0CC...0xE0D2, - 0xE0D4, - => return true, - - else => {}, - } - - // Never extend a cell that has a default background. - // A default background is applied if there is no background - // on the style or the explicitly set background - // matches our default background. - const s = self.style(cell); - const bg = s.bg(cell, palette) orelse return true; - if (bg.eql(default_background)) return true; - }, - } - } - - return false; - } - /// Iterators. These are the same as PageList iterator funcs but operate /// on pins rather than points. This is MUCH more efficient than calling /// pointFromPin and building up the iterator from points. From b8363a8417a1d87c5d491aa387751c11e3512906 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 19 Nov 2025 13:02:46 -1000 Subject: [PATCH 066/209] terminal: update render state for new dirty tracking --- src/terminal/render.zig | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/src/terminal/render.zig b/src/terminal/render.zig index b36550650..7e34aeb90 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -326,6 +326,10 @@ pub const RenderState = struct { const row_cells = row_data.items(.cells); const row_dirties = row_data.items(.dirty); + // Track the last page that we know was dirty. This lets us + // more quickly do the full-page dirty check. + var last_dirty_page: ?*page.Page = null; + // Go through and setup our rows. var row_it = s.pages.rowIterator( .right_down, @@ -353,12 +357,34 @@ pub const RenderState = struct { }; } - // If the row isn't dirty then we assume it is unchanged. - var dirty_set = row_pin.node.data.dirtyBitSet(); - if (!redraw and !dirty_set.isSet(row_pin.y)) continue; + // Get all our cells in the page. + const p: *page.Page = &row_pin.node.data; + const page_rac = row_pin.rowAndCell(); - // Clear the dirty flag on the row - dirty_set.unset(row_pin.y); + dirty: { + // If we're redrawing then we're definitely dirty. + if (redraw) break :dirty; + + // If our page is the same as last time then its dirty. + if (p == last_dirty_page) break :dirty; + if (p.dirty) { + // If this page is dirty then clear the dirty flag + // of the last page and then store this one. This benchmarks + // faster than iterating pages again later. + if (last_dirty_page) |last_p| last_p.dirty = false; + last_dirty_page = p; + } + + // If our row is dirty then we're dirty. + if (page_rac.row.dirty) break :dirty; + + // Not dirty! + continue; + } + + // Clear our row dirty, we'll clear our page dirty later. + // We can't clear it now because we have more rows to go through. + page_rac.row.dirty = false; // Promote our arena. State is copied by value so we need to // restore it on all exit paths so we don't leak memory. @@ -373,8 +399,6 @@ pub const RenderState = struct { row_dirties[y] = true; // Get all our cells in the page. - const p: *page.Page = &row_pin.node.data; - const page_rac = row_pin.rowAndCell(); const page_cells: []const page.Cell = p.getCells(page_rac.row); assert(page_cells.len == self.cols); @@ -471,6 +495,9 @@ pub const RenderState = struct { _ = sel; } + // Finalize our final dirty page + if (last_dirty_page) |last_p| last_p.dirty = false; + // Clear our dirty flags t.flags.dirty = .{}; s.dirty = .{}; From 81142265aae433a9e659c7e4fed3b7c3c83ece8f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 19 Nov 2025 14:39:41 -1000 Subject: [PATCH 067/209] terminal: renderstate stores pins --- src/renderer/generic.zig | 13 ++++++------- src/terminal/render.zig | 12 ++++++++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 48be0d95e..42bcb8d1f 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1061,9 +1061,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { state: *renderer.State, cursor_blink_visible: bool, ) !void { + // Create an arena for all our temporary allocations while rebuilding + var arena = ArenaAllocator.init(self.alloc); + defer arena.deinit(); + const arena_alloc = arena.allocator(); + // Data we extract out of the critical area. const Critical = struct { - mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, cursor_style: ?renderer.CursorStyle, scrollbar: terminal.Scrollbar, @@ -1111,9 +1115,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const preedit: ?renderer.State.Preedit = preedit: { if (cursor_style == null) break :preedit null; const p = state.preedit orelse break :preedit null; - break :preedit try p.clone(self.alloc); + break :preedit try p.clone(arena_alloc); }; - errdefer if (preedit) |p| p.deinit(self.alloc); // If we have Kitty graphics data, we enter a SLOW SLOW SLOW path. // We only do this if the Kitty image state is dirty meaning only if @@ -1129,15 +1132,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } break :critical .{ - .mouse = state.mouse, .preedit = preedit, .cursor_style = cursor_style, .scrollbar = scrollbar, }; }; - defer { - if (critical.preedit) |p| p.deinit(self.alloc); - } // Build our GPU cells try self.rebuildCells( diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 7e34aeb90..395009ec9 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -149,6 +149,10 @@ pub const RenderState = struct { /// change often. arena: ArenaAllocator.State, + /// The page pin. This is not safe to read unless you can guarantee + /// the terminal state hasn't changed since the last `update` call. + pin: Pin, + /// Raw row data. raw: page.Row, @@ -299,6 +303,7 @@ pub const RenderState = struct { for (old_len..self.rows) |y| { row_data.set(y, .{ .arena = .{}, + .pin = undefined, .raw = undefined, .cells = .empty, .dirty = true, @@ -322,6 +327,7 @@ pub const RenderState = struct { // Break down our row data const row_data = self.row_data.slice(); const row_arenas = row_data.items(.arena); + const row_pins = row_data.items(.pin); const row_raws = row_data.items(.raw); const row_cells = row_data.items(.cells); const row_dirties = row_data.items(.dirty); @@ -357,6 +363,12 @@ pub const RenderState = struct { }; } + // Store our pin. We have to store these even if we're not dirty + // because dirty is only a renderer optimization. It doesn't + // apply to memory movement. This will let us remap any cell + // pins back to an exact entry in our RenderState. + row_pins[y] = row_pin; + // Get all our cells in the page. const p: *page.Page = &row_pin.node.data; const page_rac = row_pin.rowAndCell(); From fa26e9a384e609388244d200a9bce9a479552dbe Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 19 Nov 2025 15:29:01 -1000 Subject: [PATCH 068/209] terminal: OSC8 hyperlinks in render state --- src/renderer/generic.zig | 47 +++++++++++++++++++----- src/terminal/render.zig | 79 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 9 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 42bcb8d1f..02f3a7357 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -5,6 +5,7 @@ const wuffs = @import("wuffs"); const apprt = @import("../apprt.zig"); const configpkg = @import("../config.zig"); const font = @import("../font/main.zig"); +const inputpkg = @import("../input.zig"); const os = @import("../os/main.zig"); const terminal = @import("../terminal/main.zig"); const renderer = @import("../renderer.zig"); @@ -1068,6 +1069,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Data we extract out of the critical area. const Critical = struct { + osc8_links: terminal.RenderState.CellSet, preedit: ?renderer.State.Preedit, cursor_style: ?renderer.CursorStyle, scrollbar: terminal.Scrollbar, @@ -1131,7 +1133,27 @@ pub fn Renderer(comptime GraphicsAPI: type) type { try self.prepKittyGraphics(state.terminal); } + // Get our OSC8 links we're hovering if we have a mouse. + // This requires terminal state because of URLs. + const osc8_links: terminal.RenderState.CellSet = osc8: { + // If our mouse isn't hovering, we have no links. + const vp = state.mouse.point orelse break :osc8 .empty; + + // If the right mods aren't pressed, then we can't match. + if (!state.mouse.mods.equal(inputpkg.ctrlOrSuper(.{}))) + break :osc8 .empty; + + break :osc8 self.terminal_state.linkCells( + arena_alloc, + vp, + ) catch |err| { + log.warn("error searching for OSC8 links err={}", .{err}); + break :osc8 .empty; + }; + }; + break :critical .{ + .osc8_links = osc8_links, .preedit = preedit, .cursor_style = cursor_style, .scrollbar = scrollbar, @@ -1142,6 +1164,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { try self.rebuildCells( critical.preedit, critical.cursor_style, + &critical.osc8_links, ); // Notify our shaper we're done for the frame. For some shapers, @@ -2225,6 +2248,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self: *Self, preedit: ?renderer.State.Preedit, cursor_style_: ?renderer.CursorStyle, + osc8_links: *const terminal.RenderState.CellSet, ) !void { const state: *terminal.RenderState = &self.terminal_state; defer state.redraw = false; @@ -2619,18 +2643,23 @@ pub fn Renderer(comptime GraphicsAPI: type) type { continue; } - // TODO: renderstate // Give links a single underline, unless they already have // an underline, in which case use a double underline to // distinguish them. - // const underline: terminal.Attribute.Underline = if (link_match_set.contains(screen, cell_pin)) - // if (style.flags.underline == .single) - // .double - // else - // .single - // else - // style.flags.underline; - const underline = style.flags.underline; + const underline: terminal.Attribute.Underline = underline: { + // TODO: renderstate regex links + + if (osc8_links.contains(.{ + .x = @intCast(x), + .y = @intCast(y), + })) { + break :underline if (style.flags.underline == .single) + .double + else + .single; + } + break :underline style.flags.underline; + }; // We draw underlines first so that they layer underneath text. // This improves readability when a colored underline is used diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 395009ec9..381fbf12f 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -17,6 +17,7 @@ const Terminal = @import("Terminal.zig"); // - tests for cursor state // - tests for dirty state // - tests for colors +// - tests for linkCells // Developer note: this is in src/terminal and not src/renderer because // the goal is that this remains generic to multiple renderers. This can @@ -514,6 +515,84 @@ pub const RenderState = struct { t.flags.dirty = .{}; s.dirty = .{}; } + + /// A set of coordinates representing cells. + pub const CellSet = std.AutoArrayHashMapUnmanaged(point.Coordinate, void); + + /// Returns a map of the cells that match to an OSC8 hyperlink over the + /// given point in the render state. + /// + /// IMPORTANT: The terminal must not have updated since the last call to + /// `update`. If there is any chance the terminal has updated, the caller + /// must first call `update` again to refresh the render state. + /// + /// For example, you may want to hold a lock for the duration of the + /// update and hyperlink lookup to ensure no updates happen in between. + pub fn linkCells( + self: *const RenderState, + alloc: Allocator, + viewport_point: point.Coordinate, + ) Allocator.Error!CellSet { + var result: CellSet = .empty; + errdefer result.deinit(alloc); + + const row_slice = self.row_data.slice(); + const row_pins = row_slice.items(.pin); + const row_cells = row_slice.items(.cells); + + // Grab our link ID + const link_page: *page.Page = &row_pins[viewport_point.y].node.data; + const link = link: { + const rac = link_page.getRowAndCell( + viewport_point.x, + viewport_point.y, + ); + + // The likely scenario is that our mouse isn't even over a link. + if (!rac.cell.hyperlink) { + @branchHint(.likely); + return result; + } + + const link_id = link_page.lookupHyperlink(rac.cell) orelse + return result; + break :link link_page.hyperlink_set.get( + link_page.memory, + link_id, + ); + }; + + for ( + 0.., + row_pins, + row_cells, + ) |y, pin, cells| { + for (0.., cells.items(.raw)) |x, cell| { + if (!cell.hyperlink) continue; + + const other_page: *page.Page = &pin.node.data; + const other = link: { + const rac = other_page.getRowAndCell(x, y); + const link_id = other_page.lookupHyperlink(rac.cell) orelse continue; + break :link other_page.hyperlink_set.get( + other_page.memory, + link_id, + ); + }; + + if (link.eql( + link_page.memory, + other, + other_page.memory, + )) try result.put(alloc, .{ + .y = @intCast(y), + .x = @intCast(x), + }, {}); + } + } + + return result; + } }; test "styled" { From cd00a8a2ab661da2c38c16c5b3a616296a9da415 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 20 Nov 2025 05:44:18 -1000 Subject: [PATCH 069/209] renderer: handle normal non-osc8 links with new render state --- src/renderer/generic.zig | 39 +-- src/renderer/link.zig | 677 ++++++++++----------------------------- src/terminal/render.zig | 64 +++- 3 files changed, 250 insertions(+), 530 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 02f3a7357..591b0643b 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1069,14 +1069,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Data we extract out of the critical area. const Critical = struct { - osc8_links: terminal.RenderState.CellSet, + links: terminal.RenderState.CellSet, + mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, cursor_style: ?renderer.CursorStyle, scrollbar: terminal.Scrollbar, }; // Update all our data as tightly as possible within the mutex. - const critical: Critical = critical: { + var critical: Critical = critical: { // const start = try std.time.Instant.now(); // const start_micro = std.time.microTimestamp(); // defer { @@ -1135,7 +1136,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Get our OSC8 links we're hovering if we have a mouse. // This requires terminal state because of URLs. - const osc8_links: terminal.RenderState.CellSet = osc8: { + const links: terminal.RenderState.CellSet = osc8: { // If our mouse isn't hovering, we have no links. const vp = state.mouse.point orelse break :osc8 .empty; @@ -1153,18 +1154,31 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }; break :critical .{ - .osc8_links = osc8_links, + .links = links, + .mouse = state.mouse, .preedit = preedit, .cursor_style = cursor_style, .scrollbar = scrollbar, }; }; + // Outside the critical area we can update our links to contain + // our regex results. + self.config.links.renderCellMap( + arena_alloc, + &critical.links, + &self.terminal_state, + state.mouse.point, + state.mouse.mods, + ) catch |err| { + log.warn("error searching for regex links err={}", .{err}); + }; + // Build our GPU cells try self.rebuildCells( critical.preedit, critical.cursor_style, - &critical.osc8_links, + &critical.links, ); // Notify our shaper we're done for the frame. For some shapers, @@ -2248,7 +2262,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self: *Self, preedit: ?renderer.State.Preedit, cursor_style_: ?renderer.CursorStyle, - osc8_links: *const terminal.RenderState.CellSet, + links: *const terminal.RenderState.CellSet, ) !void { const state: *terminal.RenderState = &self.terminal_state; defer state.redraw = false; @@ -2264,15 +2278,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // std.log.warn("[rebuildCells time] {}\t{}", .{start_micro, end.since(start) / std.time.ns_per_us}); // } - // TODO: renderstate - // Create our match set for the links. - // var link_match_set: link.MatchSet = if (mouse.point) |mouse_pt| try self.config.links.matchSet( - // arena_alloc, - // screen, - // mouse_pt, - // mouse.mods, - // ) else .{}; - // Determine our x/y range for preedit. We don't want to render anything // here because we will render the preedit separately. const preedit_range: ?struct { @@ -2647,9 +2652,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // an underline, in which case use a double underline to // distinguish them. const underline: terminal.Attribute.Underline = underline: { - // TODO: renderstate regex links - - if (osc8_links.contains(.{ + if (links.contains(.{ .x = @intCast(x), .y = @intCast(y), })) { diff --git a/src/renderer/link.zig b/src/renderer/link.zig index e16a85a68..8c09a3195 100644 --- a/src/renderer/link.zig +++ b/src/renderer/link.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const assert = std.debug.assert; const Allocator = std.mem.Allocator; const oni = @import("oniguruma"); const configpkg = @import("../config.zig"); @@ -54,354 +55,105 @@ pub const Set = struct { alloc.free(self.links); } - /// Returns the matchset for the viewport state. The matchset is the - /// full set of matching links for the visible viewport. A link - /// only matches if it is also in the correct state (i.e. hovered - /// if necessary). - /// - /// This is not a particularly efficient operation. This should be - /// called sparingly. - pub fn matchSet( - self: *const Set, - alloc: Allocator, - screen: *Screen, - mouse_vp_pt: point.Coordinate, - mouse_mods: inputpkg.Mods, - ) !MatchSet { - // Convert the viewport point to a screen point. - const mouse_pin = screen.pages.pin(.{ - .viewport = mouse_vp_pt, - }) orelse return .{}; - - // This contains our list of matches. The matches are stored - // as selections which contain the start and end points of - // the match. There is no way to map these back to the link - // configuration right now because we don't need to. - var matches: std.ArrayList(terminal.Selection) = .empty; - defer matches.deinit(alloc); - - // If our mouse is over an OSC8 link, then we can skip the regex - // matches below since OSC8 takes priority. - try self.matchSetFromOSC8( - alloc, - &matches, - screen, - mouse_pin, - mouse_mods, - ); - - // If we have no matches then we can try the regex matches. - if (matches.items.len == 0) { - try self.matchSetFromLinks( - alloc, - &matches, - screen, - mouse_pin, - mouse_mods, - ); - } - - return .{ .matches = try matches.toOwnedSlice(alloc) }; - } - - fn matchSetFromOSC8( - self: *const Set, - alloc: Allocator, - matches: *std.ArrayList(terminal.Selection), - screen: *Screen, - mouse_pin: terminal.Pin, - mouse_mods: inputpkg.Mods, - ) !void { - // If the right mods aren't pressed, then we can't match. - if (!mouse_mods.equal(inputpkg.ctrlOrSuper(.{}))) return; - - // Check if the cell the mouse is over is an OSC8 hyperlink - const mouse_cell = mouse_pin.rowAndCell().cell; - if (!mouse_cell.hyperlink) return; - - // Get our hyperlink entry - const page: *terminal.Page = &mouse_pin.node.data; - const link_id = page.lookupHyperlink(mouse_cell) orelse { - log.warn("failed to find hyperlink for cell", .{}); - return; - }; - const link = page.hyperlink_set.get(page.memory, link_id); - - // If our link has an implicit ID (no ID set explicitly via OSC8) - // then we use an alternate matching technique that iterates forward - // and backward until it finds boundaries. - if (link.id == .implicit) { - const uri = link.uri.slice(page.memory); - return try self.matchSetFromOSC8Implicit( - alloc, - matches, - mouse_pin, - uri, - ); - } - - // Go through every row and find matching hyperlinks for the given ID. - // Note the link ID is not the same as the OSC8 ID parameter. But - // we hash hyperlinks by their contents which should achieve the same - // thing so we can use the ID as a key. - var current: ?terminal.Selection = null; - var row_it = screen.pages.getTopLeft(.viewport).rowIterator(.right_down, null); - while (row_it.next()) |row_pin| { - const row = row_pin.rowAndCell().row; - - // If the row doesn't have any hyperlinks then we're done - // building our matching selection. - if (!row.hyperlink) { - if (current) |sel| { - try matches.append(alloc, sel); - current = null; - } - - continue; - } - - // We have hyperlinks, look for our own matching hyperlink. - for (row_pin.cells(.right), 0..) |*cell, x| { - const match = match: { - if (cell.hyperlink) { - if (row_pin.node.data.lookupHyperlink(cell)) |cell_link_id| { - break :match cell_link_id == link_id; - } - } - break :match false; - }; - - // If we have a match, extend our selection or start a new - // selection. - if (match) { - const cell_pin = row_pin.right(x); - if (current) |*sel| { - sel.endPtr().* = cell_pin; - } else { - current = .init( - cell_pin, - cell_pin, - false, - ); - } - - continue; - } - - // No match, if we have a current selection then complete it. - if (current) |sel| { - try matches.append(alloc, sel); - current = null; - } - } - } - } - - /// Match OSC8 links around the mouse pin for an OSC8 link with an - /// implicit ID. This only matches cells with the same URI directly - /// around the mouse pin. - fn matchSetFromOSC8Implicit( - self: *const Set, - alloc: Allocator, - matches: *std.ArrayList(terminal.Selection), - mouse_pin: terminal.Pin, - uri: []const u8, - ) !void { - _ = self; - - // Our selection starts with just our pin. - var sel = terminal.Selection.init(mouse_pin, mouse_pin, false); - - // Expand it to the left. - var it = mouse_pin.cellIterator(.left_up, null); - while (it.next()) |cell_pin| { - const page: *terminal.Page = &cell_pin.node.data; - const rac = cell_pin.rowAndCell(); - const cell = rac.cell; - - // If this cell isn't a hyperlink then we've found a boundary - if (!cell.hyperlink) break; - - const link_id = page.lookupHyperlink(cell) orelse { - log.warn("failed to find hyperlink for cell", .{}); - break; - }; - const link = page.hyperlink_set.get(page.memory, link_id); - - // If this link has an explicit ID then we found a boundary - if (link.id != .implicit) break; - - // If this link has a different URI then we found a boundary - const cell_uri = link.uri.slice(page.memory); - if (!std.mem.eql(u8, uri, cell_uri)) break; - - sel.startPtr().* = cell_pin; - } - - // Expand it to the right - it = mouse_pin.cellIterator(.right_down, null); - while (it.next()) |cell_pin| { - const page: *terminal.Page = &cell_pin.node.data; - const rac = cell_pin.rowAndCell(); - const cell = rac.cell; - - // If this cell isn't a hyperlink then we've found a boundary - if (!cell.hyperlink) break; - - const link_id = page.lookupHyperlink(cell) orelse { - log.warn("failed to find hyperlink for cell", .{}); - break; - }; - const link = page.hyperlink_set.get(page.memory, link_id); - - // If this link has an explicit ID then we found a boundary - if (link.id != .implicit) break; - - // If this link has a different URI then we found a boundary - const cell_uri = link.uri.slice(page.memory); - if (!std.mem.eql(u8, uri, cell_uri)) break; - - sel.endPtr().* = cell_pin; - } - - try matches.append(alloc, sel); - } - /// Fills matches with the matches from regex link matches. - fn matchSetFromLinks( + pub fn renderCellMap( self: *const Set, alloc: Allocator, - matches: *std.ArrayList(terminal.Selection), - screen: *Screen, - mouse_pin: terminal.Pin, + result: *terminal.RenderState.CellSet, + render_state: *const terminal.RenderState, + mouse_viewport: ?point.Coordinate, mouse_mods: inputpkg.Mods, ) !void { - // Iterate over all the visible lines. - var lineIter = screen.lineIterator(screen.pages.pin(.{ - .viewport = .{}, - }) orelse return); - while (lineIter.next()) |line_sel| { - const strmap: terminal.StringMap = strmap: { - var strmap: terminal.StringMap = undefined; - const str = screen.selectionString(alloc, .{ - .sel = line_sel, - .trim = false, - .map = &strmap, - }) catch |err| { - log.warn( - "failed to build string map for link checking err={}", - .{err}, - ); - continue; - }; - alloc.free(str); - break :strmap strmap; - }; - defer strmap.deinit(alloc); + // Fast path, not very likely since we have default links. + if (self.links.len == 0) return; + + // Convert our render state to a string + byte map. + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + var map: terminal.RenderState.StringMap = .empty; + defer map.deinit(alloc); + try render_state.string(&builder.writer, .{ + .alloc = alloc, + .map = &map, + }); + + const str = builder.writer.buffered(); + + // Go through each link and see if we have any matches. + for (self.links) |*link| { + // Determine if our highlight conditions are met. We use a + // switch here instead of an if so that we can get a compile + // error if any other conditions are added. + switch (link.highlight) { + .always => {}, + .always_mods => |v| if (!mouse_mods.equal(v)) continue, + + // We check the hover points later. + .hover => if (mouse_viewport == null) continue, + .hover_mods => |v| { + if (mouse_viewport == null) continue; + if (!mouse_mods.equal(v)) continue; + }, + } + + var offset: usize = 0; + while (offset < str.len) { + var region = link.regex.search( + str[offset..], + .{}, + ) catch |err| switch (err) { + error.Mismatch => break, + else => return err, + }; + defer region.deinit(); + + // We have a match! + const offset_start: usize = @intCast(region.starts()[0]); + const offset_end: usize = @intCast(region.ends()[0]); + const start = offset + offset_start; + const end = offset + offset_end; + + // Increment our offset by the number of bytes in the match. + // We defer this so that we can return the match before + // modifying the offset. + defer offset = end; - // Go through each link and see if we have any matches. - for (self.links) |link| { - // Determine if our highlight conditions are met. We use a - // switch here instead of an if so that we can get a compile - // error if any other conditions are added. switch (link.highlight) { - .always => {}, - .always_mods => |v| if (!mouse_mods.equal(v)) continue, - inline .hover, .hover_mods => |v, tag| { - if (!line_sel.contains(screen, mouse_pin)) continue; - if (comptime tag == .hover_mods) { - if (!mouse_mods.equal(v)) continue; - } - }, + .always, .always_mods => {}, + .hover, .hover_mods => if (mouse_viewport) |vp| { + for (map.items[start..end]) |pt| { + if (pt.eql(vp)) break; + } else continue; + } else continue, } - var it = strmap.searchIterator(link.regex); - while (true) { - const match_ = it.next() catch |err| { - log.warn("failed to search for link err={}", .{err}); - break; - }; - var match = match_ orelse break; - defer match.deinit(); - const sel = match.selection(); - - // If this is a highlight link then we only want to - // include matches that include our hover point. - switch (link.highlight) { - .always, .always_mods => {}, - .hover, - .hover_mods, - => if (!sel.contains(screen, mouse_pin)) continue, - } - - try matches.append(alloc, sel); + // Record the match + for (map.items[start..end]) |pt| { + try result.put(alloc, pt, {}); } } } } }; -/// MatchSet is the result of matching links against a screen. This contains -/// all the matching links and operations on them such as whether a specific -/// cell is part of a matched link. -pub const MatchSet = struct { - /// The matches. - /// - /// Important: this must be in left-to-right top-to-bottom order. - matches: []const terminal.Selection = &.{}, - i: usize = 0, - - pub fn deinit(self: *MatchSet, alloc: Allocator) void { - alloc.free(self.matches); - } - - /// Checks if the matchset contains the given pin. This is slower than - /// orderedContains but is stateless and more flexible since it doesn't - /// require the points to be in order. - pub fn contains( - self: *MatchSet, - screen: *const Screen, - pin: terminal.Pin, - ) bool { - for (self.matches) |sel| { - if (sel.contains(screen, pin)) return true; - } - - return false; - } - - /// Checks if the matchset contains the given pt. The points must be - /// given in left-to-right top-to-bottom order. This is a stateful - /// operation and giving a point out of order can cause invalid - /// results. - pub fn orderedContains( - self: *MatchSet, - screen: *const Screen, - pin: terminal.Pin, - ) bool { - // If we're beyond the end of our possible matches, we're done. - if (self.i >= self.matches.len) return false; - - // If our selection ends before the point, then no point will ever - // again match this selection so we move on to the next one. - while (self.matches[self.i].end().before(pin)) { - self.i += 1; - if (self.i >= self.matches.len) return false; - } - - return self.matches[self.i].contains(screen, pin); - } -}; - -test "matchset" { +test "renderCellMap" { const testing = std.testing; const alloc = testing.allocator; - // Initialize our screen - var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var t: terminal.Terminal = try .init(alloc, .{ + .cols = 5, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); defer s.deinit(); - const str = "1ABCD2EFGH\n3IJKL"; - try s.testWriteString(str); + const str = "1ABCD2EFGH\r\n3IJKL"; + try s.nextSlice(str); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get a set var set = try Set.fromConfig(alloc, &.{ @@ -420,46 +172,41 @@ test "matchset" { defer set.deinit(alloc); // Get our matches - var match = try set.matchSet(alloc, &s, .{}, .{}); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 2), match.matches.len); - - // Test our matches - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 0, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 2, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 3, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 1, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 2, - } }).?)); + var result: terminal.RenderState.CellSet = .empty; + defer result.deinit(alloc); + try set.renderCellMap( + alloc, + &result, + &state, + null, + .{}, + ); + try testing.expect(!result.contains(.{ .x = 0, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 0 })); + try testing.expect(result.contains(.{ .x = 2, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 3, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 1 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 2 })); } -test "matchset hover links" { +test "renderCellMap hover links" { const testing = std.testing; const alloc = testing.allocator; - // Initialize our screen - var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var t: terminal.Terminal = try .init(alloc, .{ + .cols = 5, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); defer s.deinit(); - const str = "1ABCD2EFGH\n3IJKL"; - try s.testWriteString(str); + const str = "1ABCD2EFGH\r\n3IJKL"; + try s.nextSlice(str); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get a set var set = try Set.fromConfig(alloc, &.{ @@ -479,80 +226,65 @@ test "matchset hover links" { // Not hovering over the first link { - var match = try set.matchSet(alloc, &s, .{}, .{}); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 1), match.matches.len); + var result: terminal.RenderState.CellSet = .empty; + defer result.deinit(alloc); + try set.renderCellMap( + alloc, + &result, + &state, + null, + .{}, + ); // Test our matches - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 0, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 2, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 3, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 1, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 2, - } }).?)); + try testing.expect(!result.contains(.{ .x = 0, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 2, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 3, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 1 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 2 })); } // Hovering over the first link { - var match = try set.matchSet(alloc, &s, .{ .x = 1, .y = 0 }, .{}); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 2), match.matches.len); + var result: terminal.RenderState.CellSet = .empty; + defer result.deinit(alloc); + try set.renderCellMap( + alloc, + &result, + &state, + .{ .x = 1, .y = 0 }, + .{}, + ); // Test our matches - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 0, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 2, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 3, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 1, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 2, - } }).?)); + try testing.expect(!result.contains(.{ .x = 0, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 0 })); + try testing.expect(result.contains(.{ .x = 2, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 3, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 1 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 2 })); } } -test "matchset mods no match" { +test "renderCellMap mods no match" { const testing = std.testing; const alloc = testing.allocator; - // Initialize our screen - var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var t: terminal.Terminal = try .init(alloc, .{ + .cols = 5, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); defer s.deinit(); - const str = "1ABCD2EFGH\n3IJKL"; - try s.testWriteString(str); + const str = "1ABCD2EFGH\r\n3IJKL"; + try s.nextSlice(str); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get a set var set = try Set.fromConfig(alloc, &.{ @@ -571,96 +303,21 @@ test "matchset mods no match" { defer set.deinit(alloc); // Get our matches - var match = try set.matchSet(alloc, &s, .{}, .{}); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 1), match.matches.len); - - // Test our matches - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 0, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 2, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 3, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 1, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 2, - } }).?)); -} - -test "matchset osc8" { - const testing = std.testing; - const alloc = testing.allocator; - - // Initialize our terminal - var t = try Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); - defer t.deinit(alloc); - const s: *terminal.Screen = t.screens.active; - - try t.printString("ABC"); - try t.screens.active.startHyperlink("http://example.com", null); - try t.printString("123"); - t.screens.active.endHyperlink(); - - // Get a set - var set = try Set.fromConfig(alloc, &.{}); - defer set.deinit(alloc); - - // No matches over the non-link - { - var match = try set.matchSet( - alloc, - t.screens.active, - .{ .x = 2, .y = 0 }, - inputpkg.ctrlOrSuper(.{}), - ); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 0), match.matches.len); - } - - // Match over link - var match = try set.matchSet( + var result: terminal.RenderState.CellSet = .empty; + defer result.deinit(alloc); + try set.renderCellMap( alloc, - t.screens.active, - .{ .x = 3, .y = 0 }, - inputpkg.ctrlOrSuper(.{}), + &result, + &state, + null, + .{}, ); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 1), match.matches.len); // Test our matches - try testing.expect(!match.orderedContains(s, s.pages.pin(.{ .screen = .{ - .x = 2, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(s, s.pages.pin(.{ .screen = .{ - .x = 3, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(s, s.pages.pin(.{ .screen = .{ - .x = 4, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(s, s.pages.pin(.{ .screen = .{ - .x = 5, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(s, s.pages.pin(.{ .screen = .{ - .x = 6, - .y = 0, - } }).?)); + try testing.expect(!result.contains(.{ .x = 0, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 0 })); + try testing.expect(result.contains(.{ .x = 2, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 3, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 1 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 2 })); } diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 381fbf12f..bdc4693b1 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -18,6 +18,7 @@ const Terminal = @import("Terminal.zig"); // - tests for dirty state // - tests for colors // - tests for linkCells +// - tests for string // Developer note: this is in src/terminal and not src/renderer because // the goal is that this remains generic to multiple renderers. This can @@ -329,7 +330,7 @@ pub const RenderState = struct { const row_data = self.row_data.slice(); const row_arenas = row_data.items(.arena); const row_pins = row_data.items(.pin); - const row_raws = row_data.items(.raw); + const row_rows = row_data.items(.raw); const row_cells = row_data.items(.cells); const row_dirties = row_data.items(.dirty); @@ -416,7 +417,7 @@ pub const RenderState = struct { assert(page_cells.len == self.cols); // Copy our raw row data - row_raws[y] = page_rac.row.*; + row_rows[y] = page_rac.row.*; // Note: our cells MultiArrayList uses our general allocator. // We do this on purpose because as rows become dirty, we do @@ -516,6 +517,65 @@ pub const RenderState = struct { s.dirty = .{}; } + pub const StringMap = std.ArrayListUnmanaged(point.Coordinate); + + /// Convert the current render state contents to a UTF-8 encoded + /// string written to the given writer. This will unwrap all the wrapped + /// rows. This is useful for a minimal viewport search. + /// + /// NOTE: There is a limitation in that wrapped lines before/after + /// the the top/bottom line of the viewport are not inluded, since + /// the render state cuts them off. + pub fn string( + self: *const RenderState, + writer: *std.Io.Writer, + map: ?struct { + alloc: Allocator, + map: *StringMap, + }, + ) (Allocator.Error || std.Io.Writer.Error)!void { + const row_slice = self.row_data.slice(); + const row_rows = row_slice.items(.raw); + const row_cells = row_slice.items(.cells); + + for ( + 0.., + row_rows, + row_cells, + ) |y, row, cells| { + const cells_slice = cells.slice(); + for ( + 0.., + cells_slice.items(.raw), + cells_slice.items(.grapheme), + ) |x, cell, graphemes| { + var len: usize = std.unicode.utf8CodepointSequenceLength(cell.codepoint()) catch + return error.WriteFailed; + try writer.print("{u}", .{cell.codepoint()}); + if (cell.hasGrapheme()) { + for (graphemes) |cp| { + len += std.unicode.utf8CodepointSequenceLength(cp) catch + return error.WriteFailed; + try writer.print("{u}", .{cp}); + } + } + + if (map) |m| try m.map.appendNTimes(m.alloc, .{ + .x = @intCast(x), + .y = @intCast(y), + }, len); + } + + if (!row.wrap) { + try writer.writeAll("\n"); + if (map) |m| try m.map.append(m.alloc, .{ + .x = @intCast(cells_slice.len), + .y = @intCast(y), + }); + } + } + } + /// A set of coordinates representing cells. pub const CellSet = std.AutoArrayHashMapUnmanaged(point.Coordinate, void); From 6e5e24c3ca791470574dfcfef1793d2a9562d1be Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 20 Nov 2025 06:13:05 -1000 Subject: [PATCH 070/209] terminal: fix lib-vt test builds --- src/terminal/render.zig | 2 +- src/terminal/search.zig | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/terminal/render.zig b/src/terminal/render.zig index bdc4693b1..94abeb60e 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -524,7 +524,7 @@ pub const RenderState = struct { /// rows. This is useful for a minimal viewport search. /// /// NOTE: There is a limitation in that wrapped lines before/after - /// the the top/bottom line of the viewport are not inluded, since + /// the the top/bottom line of the viewport are not included, since /// the render state cuts them off. pub fn string( self: *const RenderState, diff --git a/src/terminal/search.zig b/src/terminal/search.zig index 0f0c53c03..e69603c25 100644 --- a/src/terminal/search.zig +++ b/src/terminal/search.zig @@ -1,10 +1,18 @@ //! Search functionality for the terminal. +pub const options = @import("terminal_options"); + pub const Active = @import("search/active.zig").ActiveSearch; pub const PageList = @import("search/pagelist.zig").PageListSearch; pub const Screen = @import("search/screen.zig").ScreenSearch; pub const Viewport = @import("search/viewport.zig").ViewportSearch; -pub const Thread = @import("search/Thread.zig"); + +// The search thread is not available in libghostty due to the xev dep +// for now. +pub const Thread = switch (options.artifact) { + .ghostty => @import("search/Thread.zig"), + .lib => void, +}; test { @import("std").testing.refAllDecls(@This()); From 5d58487fb8d9382121842e7168f300da52032961 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 20 Nov 2025 06:15:30 -1000 Subject: [PATCH 071/209] terminal: update renderstate to use new assert --- src/terminal/render.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 94abeb60e..25399033e 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const fastmem = @import("../fastmem.zig"); From a15f13b9628348d490d305f3fc68f74e974644eb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 20 Nov 2025 06:25:04 -1000 Subject: [PATCH 072/209] terminal: renderstate tests --- src/terminal/Terminal.zig | 4 +- src/terminal/render.zig | 241 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 235 insertions(+), 10 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index e02b58e57..1ec5b5d47 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1417,10 +1417,10 @@ pub fn scrollUp(self: *Terminal, count: usize) void { /// Options for scrolling the viewport of the terminal grid. pub const ScrollViewport = union(enum) { /// Scroll to the top of the scrollback - top: void, + top, /// Scroll to the bottom, i.e. the top of the active area - bottom: void, + bottom, /// Scroll by some delta amount, up is negative. delta: isize, diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 25399033e..9db7ce897 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -13,13 +13,6 @@ const ScreenSet = @import("ScreenSet.zig"); const Style = @import("style.zig").Style; const Terminal = @import("Terminal.zig"); -// TODO: -// - tests for cursor state -// - tests for dirty state -// - tests for colors -// - tests for linkCells -// - tests for string - // Developer note: this is in src/terminal and not src/renderer because // the goal is that this remains generic to multiple renderers. This can // aid specifically with libghostty-vt with converting terminal state to @@ -261,7 +254,7 @@ pub const RenderState = struct { self.viewport_pin = viewport_pin; self.cursor.active = .{ .x = s.cursor.x, .y = s.cursor.y }; self.cursor.cell = s.cursor.page_cell.*; - self.cursor.style = s.cursor.page_pin.style(s.cursor.page_cell); + self.cursor.style = s.cursor.style; // Always reset the cursor viewport position. In the future we can // probably cache this by comparing the cursor pin and viewport pin @@ -523,6 +516,10 @@ pub const RenderState = struct { /// string written to the given writer. This will unwrap all the wrapped /// rows. This is useful for a minimal viewport search. /// + /// This currently writes empty cell contents as \x00 and writes all + /// blank lines. This is fine for our current usage (link search) but + /// we can adjust this later. + /// /// NOTE: There is a limitation in that wrapped lines before/after /// the the top/bottom line of the viewport are not included, since /// the render state cuts them off. @@ -801,3 +798,231 @@ test "grapheme" { try testing.expectEqual(.spacer_tail, cell.raw.wide); } } + +test "cursor state in viewport" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 5, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("A\x1b[H"); + + var state: RenderState = .empty; + defer state.deinit(alloc); + + // Initial update + try state.update(alloc, &t); + try testing.expectEqual(0, state.cursor.active.x); + try testing.expectEqual(0, state.cursor.active.y); + try testing.expectEqual(0, state.cursor.viewport.?.x); + try testing.expectEqual(0, state.cursor.viewport.?.y); + try testing.expectEqual('A', state.cursor.cell.codepoint()); + try testing.expect(state.cursor.style.default()); + + // Set a style on the cursor + try s.nextSlice("\x1b[1m"); // Bold + try state.update(alloc, &t); + try testing.expect(!state.cursor.style.default()); + try testing.expect(state.cursor.style.flags.bold); + try s.nextSlice("\x1b[0m"); // Reset style + + // Move cursor to 2,1 + try s.nextSlice("\x1b[2;3H"); + try state.update(alloc, &t); + try testing.expectEqual(2, state.cursor.active.x); + try testing.expectEqual(1, state.cursor.active.y); + try testing.expectEqual(2, state.cursor.viewport.?.x); + try testing.expectEqual(1, state.cursor.viewport.?.y); +} + +test "cursor state out of viewport" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 2, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("A\r\nB\r\nC\r\nD\r\n"); + + var state: RenderState = .empty; + defer state.deinit(alloc); + + // Initial update + try state.update(alloc, &t); + try testing.expectEqual(0, state.cursor.active.x); + try testing.expectEqual(1, state.cursor.active.y); + try testing.expectEqual(0, state.cursor.viewport.?.x); + try testing.expectEqual(1, state.cursor.viewport.?.y); + + // Scroll the viewport + try t.scrollViewport(.top); + try state.update(alloc, &t); + + // Set a style on the cursor + try testing.expectEqual(0, state.cursor.active.x); + try testing.expectEqual(1, state.cursor.active.y); + try testing.expect(state.cursor.viewport == null); +} + +test "dirty state" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 5, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + var state: RenderState = .empty; + defer state.deinit(alloc); + + // First update should trigger redraw due to resize + try state.update(alloc, &t); + try testing.expect(state.redraw); + + // Reset redraw flag and dirty rows + state.redraw = false; + { + const row_data = state.row_data.slice(); + const dirty = row_data.items(.dirty); + @memset(dirty, false); + } + + // Second update with no changes - no redraw, no dirty rows + try state.update(alloc, &t); + try testing.expect(!state.redraw); + { + const row_data = state.row_data.slice(); + const dirty = row_data.items(.dirty); + for (dirty) |d| try testing.expect(!d); + } + + // Write to first line + try s.nextSlice("A"); + try state.update(alloc, &t); + try testing.expect(!state.redraw); // Should not trigger full redraw + { + const row_data = state.row_data.slice(); + const dirty = row_data.items(.dirty); + try testing.expect(dirty[0]); // First row dirty + try testing.expect(!dirty[1]); // Second row clean + } +} + +test "colors" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 5, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + var state: RenderState = .empty; + defer state.deinit(alloc); + + // Default colors + try state.update(alloc, &t); + + // Change cursor color + try s.nextSlice("\x1b]12;#FF0000\x07"); + try state.update(alloc, &t); + + const c = state.colors.cursor.?; + try testing.expectEqual(0xFF, c.r); + try testing.expectEqual(0, c.g); + try testing.expectEqual(0, c.b); + + // Change palette color 0 to White + try s.nextSlice("\x1b]4;0;#FFFFFF\x07"); + try state.update(alloc, &t); + const p0 = state.colors.palette[0]; + try testing.expectEqual(0xFF, p0.r); + try testing.expectEqual(0xFF, p0.g); + try testing.expectEqual(0xFF, p0.b); +} + +test "linkCells" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 5, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + var state: RenderState = .empty; + defer state.deinit(alloc); + + // Create a hyperlink + try s.nextSlice("\x1b]8;;http://example.com\x1b\\LINK\x1b]8;;\x1b\\"); + try state.update(alloc, &t); + + // Query link at 0,0 + var cells = try state.linkCells(alloc, .{ .x = 0, .y = 0 }); + defer cells.deinit(alloc); + + try testing.expectEqual(4, cells.count()); + try testing.expect(cells.contains(.{ .x = 0, .y = 0 })); + try testing.expect(cells.contains(.{ .x = 1, .y = 0 })); + try testing.expect(cells.contains(.{ .x = 2, .y = 0 })); + try testing.expect(cells.contains(.{ .x = 3, .y = 0 })); + + // Query no link + var cells2 = try state.linkCells(alloc, .{ .x = 4, .y = 0 }); + defer cells2.deinit(alloc); + try testing.expectEqual(0, cells2.count()); +} + +test "string" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 5, + .rows = 2, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("AB"); + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + var w = std.Io.Writer.Allocating.init(alloc); + defer w.deinit(); + + try state.string(&w.writer, null); + + const result = try w.toOwnedSlice(); + defer alloc.free(result); + + const expected = "AB\x00\x00\x00\n\x00\x00\x00\x00\x00\n"; + try testing.expectEqualStrings(expected, result); +} From 86fcf9ff4a6e1ab4e5a16738ec82a140e8c39130 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 20 Nov 2025 07:02:53 -1000 Subject: [PATCH 073/209] terminal: render state selection --- src/terminal/Selection.zig | 67 +++++++++++++++++--- src/terminal/render.zig | 123 ++++++++++++++++++++++++++++++++++--- 2 files changed, 176 insertions(+), 14 deletions(-) diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index e10f83c9e..bc597fc2e 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -280,23 +280,60 @@ pub fn contains(self: Selection, s: *const Screen, pin: Pin) bool { /// Get a selection for a single row in the screen. This will return null /// if the row is not included in the selection. +/// +/// This is a very expensive operation. It has to traverse the linked list +/// of pages for the top-left, bottom-right, and the given pin to find +/// the coordinates. If you are calling this repeatedly, prefer +/// `containedRowCached`. pub fn containedRow(self: Selection, s: *const Screen, pin: Pin) ?Selection { const tl_pin = self.topLeft(s); const br_pin = self.bottomRight(s); // This is definitely not very efficient. Low-hanging fruit to - // improve this. + // improve this. Callers should prefer containedRowCached if they + // can swing it. const tl = s.pages.pointFromPin(.screen, tl_pin).?.screen; const br = s.pages.pointFromPin(.screen, br_pin).?.screen; const p = s.pages.pointFromPin(.screen, pin).?.screen; + return self.containedRowCached( + s, + tl_pin, + br_pin, + pin, + tl, + br, + p, + ); +} + +/// Same as containedRow but useful if you're calling it repeatedly +/// so that the pins can be cached across calls. Advanced. +pub fn containedRowCached( + self: Selection, + s: *const Screen, + tl_pin: Pin, + br_pin: Pin, + pin: Pin, + tl: point.Coordinate, + br: point.Coordinate, + p: point.Coordinate, +) ?Selection { if (p.y < tl.y or p.y > br.y) return null; // Rectangle case: we can return early as the x range will always be the // same. We've already validated that the row is in the selection. if (self.rectangle) return init( - s.pages.pin(.{ .screen = .{ .y = p.y, .x = tl.x } }).?, - s.pages.pin(.{ .screen = .{ .y = p.y, .x = br.x } }).?, + start: { + var copy: Pin = pin; + copy.x = tl.x; + break :start copy; + }, + end: { + var copy: Pin = pin; + copy.x = br.x; + break :end copy; + }, true, ); @@ -309,7 +346,11 @@ pub fn containedRow(self: Selection, s: *const Screen, pin: Pin) ?Selection { // Selection top-left line matches only. return init( tl_pin, - s.pages.pin(.{ .screen = .{ .y = p.y, .x = s.pages.cols - 1 } }).?, + end: { + var copy: Pin = pin; + copy.x = s.pages.cols - 1; + break :end copy; + }, false, ); } @@ -320,7 +361,11 @@ pub fn containedRow(self: Selection, s: *const Screen, pin: Pin) ?Selection { if (p.y == br.y) { assert(p.y != tl.y); return init( - s.pages.pin(.{ .screen = .{ .y = p.y, .x = 0 } }).?, + start: { + var copy: Pin = pin; + copy.x = 0; + break :start copy; + }, br_pin, false, ); @@ -328,8 +373,16 @@ pub fn containedRow(self: Selection, s: *const Screen, pin: Pin) ?Selection { // Row is somewhere between our selection lines so we return the full line. return init( - s.pages.pin(.{ .screen = .{ .y = p.y, .x = 0 } }).?, - s.pages.pin(.{ .screen = .{ .y = p.y, .x = s.pages.cols - 1 } }).?, + start: { + var copy: Pin = pin; + copy.x = 0; + break :start copy; + }, + end: { + var copy: Pin = pin; + copy.x = s.pages.cols - 1; + break :end copy; + }, false, ); } diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 9db7ce897..8bfeff501 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -7,7 +7,7 @@ const color = @import("color.zig"); const point = @import("point.zig"); const size = @import("size.zig"); const page = @import("page.zig"); -const Pin = @import("PageList.zig").Pin; +const PageList = @import("PageList.zig"); const Screen = @import("Screen.zig"); const ScreenSet = @import("ScreenSet.zig"); const Style = @import("style.zig").Style; @@ -73,7 +73,7 @@ pub const RenderState = struct { /// The last viewport pin used to generate this state. This is NOT /// a tracked pin and is generally NOT safe to read other than the direct /// values for comparison. - viewport_pin: ?Pin = null, + viewport_pin: ?PageList.Pin = null, /// Initial state. pub const empty: RenderState = .{ @@ -146,7 +146,7 @@ pub const RenderState = struct { /// The page pin. This is not safe to read unless you can guarantee /// the terminal state hasn't changed since the last `update` call. - pin: Pin, + pin: PageList.Pin, /// Raw row data. raw: page.Row, @@ -325,6 +325,7 @@ pub const RenderState = struct { const row_pins = row_data.items(.pin); const row_rows = row_data.items(.raw); const row_cells = row_data.items(.cells); + const row_sels = row_data.items(.selection); const row_dirties = row_data.items(.dirty); // Track the last page that we know was dirty. This lets us @@ -402,6 +403,7 @@ pub const RenderState = struct { if (row_cells[y].len > 0) { _ = arena.reset(.retain_capacity); row_cells[y].clearRetainingCapacity(); + row_sels[y] = null; } row_dirties[y] = true; @@ -485,21 +487,57 @@ pub const RenderState = struct { assert(y == self.rows); // If our screen has a selection, then mark the rows with the - // selection. + // selection. We do this outside of the loop above because its unlikely + // a selection exists and because the way our selections are structured + // today is very inefficient. + // + // NOTE: To improve the performance of the block below, we'll need + // to rethink how we model selections in general. + // + // There are performance improvements that can be made here, though. + // For example, `containedRow` recalculates a bunch of information + // we can cache. if (s.selection) |*sel| { @branchHint(.unlikely); + // Go through each row and check for containment. + // TODO: - // - Mark the rows with selections // - Cache the selection (untracked) so we can avoid redoing // this expensive work every frame. + // Grab the inefficient data we need from the selection. At + // least we can cache it. + const tl_pin = sel.topLeft(s); + const br_pin = sel.bottomRight(s); + const tl = s.pages.pointFromPin(.screen, tl_pin).?.screen; + const br = s.pages.pointFromPin(.screen, br_pin).?.screen; + // We need to determine if our selection is within the viewport. // The viewport is generally very small so the efficient way to // do this is to traverse the viewport pages and check for the // matching selection pages. - - _ = sel; + for ( + row_pins, + row_sels, + ) |pin, *sel_bounds| { + const p = s.pages.pointFromPin(.screen, pin).?.screen; + const row_sel = sel.containedRowCached( + s, + tl_pin, + br_pin, + pin, + tl, + br, + p, + ) orelse continue; + const start = row_sel.start(); + const end = row_sel.end(); + assert(start.node == end.node); + assert(start.x <= end.x); + assert(start.y == end.y); + sel_bounds.* = .{ start.x, end.x }; + } } // Finalize our final dirty page @@ -961,6 +999,77 @@ test "colors" { try testing.expectEqual(0xFF, p0.b); } +test "selection single line" { + const testing = std.testing; + const alloc = testing.allocator; + + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + const screen: *Screen = t.screens.active; + try screen.select(.init( + screen.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + screen.pages.pin(.{ .active = .{ .x = 2, .y = 1 } }).?, + false, + )); + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + const row_data = state.row_data.slice(); + const sels = row_data.items(.selection); + try testing.expectEqual(null, sels[0]); + try testing.expectEqualSlices(size.CellCountInt, &.{ 0, 2 }, &sels[1].?); + try testing.expectEqual(null, sels[2]); + + // Clear the selection + try screen.select(null); + try state.update(alloc, &t); + try testing.expectEqual(null, sels[0]); + try testing.expectEqual(null, sels[1]); + try testing.expectEqual(null, sels[2]); +} + +test "selection multiple lines" { + const testing = std.testing; + const alloc = testing.allocator; + + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + const screen: *Screen = t.screens.active; + try screen.select(.init( + screen.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + screen.pages.pin(.{ .active = .{ .x = 2, .y = 2 } }).?, + false, + )); + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + const row_data = state.row_data.slice(); + const sels = row_data.items(.selection); + try testing.expectEqual(null, sels[0]); + try testing.expectEqualSlices( + size.CellCountInt, + &.{ 0, screen.pages.cols - 1 }, + &sels[1].?, + ); + try testing.expectEqualSlices( + size.CellCountInt, + &.{ 0, 2 }, + &sels[2].?, + ); +} + test "linkCells" { const testing = std.testing; const alloc = testing.allocator; From 7728620ea8b86266a00e69909c33a4cd1265237f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 20 Nov 2025 20:59:12 -0800 Subject: [PATCH 074/209] terminal: render state dirty state --- src/renderer/generic.zig | 6 ++-- src/terminal/render.zig | 63 ++++++++++++++++++++++++++-------------- 2 files changed, 45 insertions(+), 24 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 591b0643b..bc7dc0321 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -937,7 +937,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// Mark the full screen as dirty so that we redraw everything. pub inline fn markDirty(self: *Self) void { - self.terminal_state.redraw = true; + self.terminal_state.dirty = .full; } /// Called when we get an updated display ID for our display link. @@ -2265,7 +2265,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { links: *const terminal.RenderState.CellSet, ) !void { const state: *terminal.RenderState = &self.terminal_state; - defer state.redraw = false; + defer state.dirty = .false; self.draw_mutex.lock(); defer self.draw_mutex.unlock(); @@ -2317,7 +2317,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.uniforms.grid_size = .{ new_size.columns, new_size.rows }; } - const rebuild = state.redraw or grid_size_diff; + const rebuild = state.dirty == .full or grid_size_diff; if (rebuild) { // If we are doing a full rebuild, then we clear the entire cell buffer. self.cells.reset(); diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 8bfeff501..8dcf67dcb 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -60,11 +60,10 @@ pub const RenderState = struct { /// use cases. row_data: std.MultiArrayList(Row), - /// This is set to true if the terminal state has changed in a way - /// that the renderer should do a full redraw of the grid. The renderer - /// should se this to false when it has done so. `update` will only - /// ever tick this to true. - redraw: bool, + /// The dirty state of the render state. This is set by the update method. + /// The renderer/caller should set this to false when it has handled + /// the dirty state. + dirty: Dirty, /// The screen type that this state represents. This is used primarily /// to detect changes. @@ -93,7 +92,7 @@ pub const RenderState = struct { .style = undefined, }, .row_data = .empty, - .redraw = false, + .dirty = .false, .screen = .primary, }; @@ -179,6 +178,21 @@ pub const RenderState = struct { style: Style, }; + // Dirty state + pub const Dirty = enum { + /// Not dirty at all. Can skip rendering if prior state was + /// already rendered. + false, + + /// Partially dirty. Some rows changed but not all. None of the + /// global state changed such as colors. + partial, + + /// Fully dirty. Global state changed or dimensions changed. All rows + /// should be redrawn. + full, + }; + pub fn deinit(self: *RenderState, alloc: Allocator) void { for ( self.row_data.items(.arena), @@ -238,15 +252,6 @@ pub const RenderState = struct { break :redraw false; }; - // Full redraw resets our state completely. - if (redraw) { - self.screen = t.screens.active_key; - self.redraw = true; - - // Note: we don't clear any row_data here because our rebuild - // below is going to do that for us. - } - // Always set our cheap fields, its more expensive to compare self.rows = s.pages.rows; self.cols = s.pages.cols; @@ -339,6 +344,7 @@ pub const RenderState = struct { null, ); var y: size.CellCountInt = 0; + var any_dirty: bool = false; while (row_it.next()) |row_pin| : (y = y + 1) { // Find our cursor if we haven't found it yet. We do this even // if the row is not dirty because the cursor is unrelated. @@ -390,6 +396,9 @@ pub const RenderState = struct { continue; } + // Set that at least one row was dirty. + any_dirty = true; + // Clear our row dirty, we'll clear our page dirty later. // We can't clear it now because we have more rows to go through. page_rac.row.dirty = false; @@ -540,6 +549,18 @@ pub const RenderState = struct { } } + // Handle dirty state. + if (redraw) { + // Fully redraw resets some other state. + self.screen = t.screens.active_key; + self.dirty = .full; + + // Note: we don't clear any row_data here because our rebuild + // above did this. + } else if (any_dirty and self.dirty == .false) { + self.dirty = .partial; + } + // Finalize our final dirty page if (last_dirty_page) |last_p| last_p.dirty = false; @@ -931,19 +952,19 @@ test "dirty state" { // First update should trigger redraw due to resize try state.update(alloc, &t); - try testing.expect(state.redraw); + try testing.expectEqual(.full, state.dirty); - // Reset redraw flag and dirty rows - state.redraw = false; + // Reset dirty flag and dirty rows + state.dirty = .false; { const row_data = state.row_data.slice(); const dirty = row_data.items(.dirty); @memset(dirty, false); } - // Second update with no changes - no redraw, no dirty rows + // Second update with no changes - no dirty rows try state.update(alloc, &t); - try testing.expect(!state.redraw); + try testing.expectEqual(.false, state.dirty); { const row_data = state.row_data.slice(); const dirty = row_data.items(.dirty); @@ -953,7 +974,7 @@ test "dirty state" { // Write to first line try s.nextSlice("A"); try state.update(alloc, &t); - try testing.expect(!state.redraw); // Should not trigger full redraw + try testing.expectEqual(.partial, state.dirty); { const row_data = state.row_data.slice(); const dirty = row_data.items(.dirty); From c892599385ce88c63278bfbf5688674a31196a41 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 20 Nov 2025 21:11:03 -0800 Subject: [PATCH 075/209] terminal: cache some selection state to make render state faster --- src/terminal/render.zig | 54 ++++++++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 8dcf67dcb..6325ef790 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -8,6 +8,7 @@ const point = @import("point.zig"); const size = @import("size.zig"); const page = @import("page.zig"); const PageList = @import("PageList.zig"); +const Selection = @import("Selection.zig"); const Screen = @import("Screen.zig"); const ScreenSet = @import("ScreenSet.zig"); const Style = @import("style.zig").Style; @@ -74,6 +75,10 @@ pub const RenderState = struct { /// values for comparison. viewport_pin: ?PageList.Pin = null, + /// The cached selection so we can avoid expensive selection calculations + /// if possible. + selection_cache: ?SelectionCache = null, + /// Initial state. pub const empty: RenderState = .{ .rows = 0, @@ -193,6 +198,12 @@ pub const RenderState = struct { full, }; + const SelectionCache = struct { + selection: Selection, + tl_pin: PageList.Pin, + br_pin: PageList.Pin, + }; + pub fn deinit(self: *RenderState, alloc: Allocator) void { for ( self.row_data.items(.arena), @@ -506,21 +517,42 @@ pub const RenderState = struct { // There are performance improvements that can be made here, though. // For example, `containedRow` recalculates a bunch of information // we can cache. - if (s.selection) |*sel| { + if (s.selection) |*sel| selection: { @branchHint(.unlikely); - // Go through each row and check for containment. + // Populate our selection cache to avoid some expensive + // recalculation. + const cache: *const SelectionCache = cache: { + if (self.selection_cache) |*c| cache_check: { + // If we're redrawing, we recalculate the cache just to + // be safe. + if (redraw) break :cache_check; - // TODO: - // - Cache the selection (untracked) so we can avoid redoing - // this expensive work every frame. + // If our selection isn't equal, we aren't cached! + if (!c.selection.eql(sel.*)) break :cache_check; + + // If we have no dirty rows, we can not recalculate. + if (!any_dirty) break :selection; + + // We have dirty rows, we can utilize the cache. + break :cache c; + } + + // Create a new cache + const tl_pin = sel.topLeft(s); + const br_pin = sel.bottomRight(s); + self.selection_cache = .{ + .selection = .init(tl_pin, br_pin, sel.rectangle), + .tl_pin = tl_pin, + .br_pin = br_pin, + }; + break :cache &self.selection_cache.?; + }; // Grab the inefficient data we need from the selection. At // least we can cache it. - const tl_pin = sel.topLeft(s); - const br_pin = sel.bottomRight(s); - const tl = s.pages.pointFromPin(.screen, tl_pin).?.screen; - const br = s.pages.pointFromPin(.screen, br_pin).?.screen; + const tl = s.pages.pointFromPin(.screen, cache.tl_pin).?.screen; + const br = s.pages.pointFromPin(.screen, cache.br_pin).?.screen; // We need to determine if our selection is within the viewport. // The viewport is generally very small so the efficient way to @@ -533,8 +565,8 @@ pub const RenderState = struct { const p = s.pages.pointFromPin(.screen, pin).?.screen; const row_sel = sel.containedRowCached( s, - tl_pin, - br_pin, + cache.tl_pin, + cache.br_pin, pin, tl, br, From 3d56a3a02b9b286a166325f448b63aab1870ef0f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 20 Nov 2025 21:42:27 -0800 Subject: [PATCH 076/209] font/shaper: remove old pre-renderstate logic --- src/font/shape.zig | 14 +- src/font/shaper/coretext.zig | 98 +--------- src/font/shaper/run.zig | 347 +---------------------------------- src/renderer/generic.zig | 7 +- 4 files changed, 9 insertions(+), 457 deletions(-) diff --git a/src/font/shape.zig b/src/font/shape.zig index e3634d68c..0d8a029bf 100644 --- a/src/font/shape.zig +++ b/src/font/shape.zig @@ -77,19 +77,7 @@ pub const RunOptions = struct { cells: std.MultiArrayList(terminal.RenderState.Cell).Slice = .empty, /// The x boundaries of the selection in this row. - selection2: ?[2]u16 = null, - - /// The terminal screen to shape. - screen: *const terminal.Screen, - - /// The row within the screen to shape. This row must exist within - /// screen; it is not validated. - row: terminal.Pin, - - /// The selection boundaries. This is used to break shaping on - /// selection boundaries. This can be disabled by setting this to - /// null. - selection: ?terminal.Selection = null, + selection: ?[2]u16 = null, /// The cursor position within this row. This is used to break shaping /// on cursor boundaries. This can be disabled by setting this to diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 41fa88758..c1deec11d 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -644,8 +644,6 @@ test "run iterator" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -669,8 +667,6 @@ test "run iterator" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -695,8 +691,6 @@ test "run iterator" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -722,8 +716,6 @@ test "run iterator" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -776,8 +768,6 @@ test "run iterator: empty cells with background set" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); { const run = (try it.next(alloc)).?; @@ -818,8 +808,6 @@ test "shape" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -859,8 +847,6 @@ test "shape nerd fonts" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -893,8 +879,6 @@ test "shape inconsolata ligs" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -924,8 +908,6 @@ test "shape inconsolata ligs" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -963,8 +945,6 @@ test "shape monaspace ligs" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1003,8 +983,6 @@ test "shape left-replaced lig in last run" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1043,8 +1021,6 @@ test "shape left-replaced lig in early run" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); const run = (try it.next(alloc)).?; @@ -1080,8 +1056,6 @@ test "shape U+3C9 with JB Mono" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var run_count: usize = 0; @@ -1119,8 +1093,6 @@ test "shape emoji width" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1172,8 +1144,6 @@ test "shape emoji width long" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(1).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1214,8 +1184,6 @@ test "shape variation selector VS15" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1255,8 +1223,6 @@ test "shape variation selector VS16" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1293,8 +1259,6 @@ test "shape with empty cells in between" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1337,8 +1301,6 @@ test "shape Chinese characters" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1386,8 +1348,6 @@ test "shape Devanagari string" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); const run = try it.next(alloc); @@ -1436,8 +1396,6 @@ test "shape box glyphs" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1478,9 +1436,7 @@ test "shape selection boundary" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, - .selection2 = .{ 0, @intCast(t.cols - 1) }, + .selection = .{ 0, @intCast(t.cols - 1) }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1497,9 +1453,7 @@ test "shape selection boundary" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, - .selection2 = .{ 2, @intCast(t.cols - 1) }, + .selection = .{ 2, @intCast(t.cols - 1) }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1516,9 +1470,7 @@ test "shape selection boundary" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, - .selection2 = .{ 0, 3 }, + .selection = .{ 0, 3 }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1535,9 +1487,7 @@ test "shape selection boundary" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, - .selection2 = .{ 1, 3 }, + .selection = .{ 1, 3 }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1554,9 +1504,7 @@ test "shape selection boundary" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, - .selection2 = .{ 1, 1 }, + .selection = .{ 1, 1 }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1593,8 +1541,6 @@ test "shape cursor boundary" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1612,8 +1558,6 @@ test "shape cursor boundary" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, .cursor_x = 0, }); var count: usize = 0; @@ -1630,8 +1574,6 @@ test "shape cursor boundary" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1650,8 +1592,6 @@ test "shape cursor boundary" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, .cursor_x = 1, }); var count: usize = 0; @@ -1668,8 +1608,6 @@ test "shape cursor boundary" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1687,8 +1625,6 @@ test "shape cursor boundary" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, .cursor_x = 9, }); var count: usize = 0; @@ -1705,8 +1641,6 @@ test "shape cursor boundary" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1744,8 +1678,6 @@ test "shape cursor boundary and colored emoji" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1762,8 +1694,6 @@ test "shape cursor boundary and colored emoji" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, .cursor_x = 0, }); var count: usize = 0; @@ -1779,8 +1709,6 @@ test "shape cursor boundary and colored emoji" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1795,8 +1723,6 @@ test "shape cursor boundary and colored emoji" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, .cursor_x = 1, }); var count: usize = 0; @@ -1812,8 +1738,6 @@ test "shape cursor boundary and colored emoji" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1848,8 +1772,6 @@ test "shape cell attribute change" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1878,8 +1800,6 @@ test "shape cell attribute change" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1911,8 +1831,6 @@ test "shape cell attribute change" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1944,8 +1862,6 @@ test "shape cell attribute change" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1975,8 +1891,6 @@ test "shape cell attribute change" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -2020,8 +1934,6 @@ test "shape high plane sprite font codepoint" { var it = shaper.runIterator(.{ .grid = testdata.grid, .cells = state.row_data.get(0).cells.slice(), - .screen = undefined, - .row = undefined, }); // We should get one run const run = (try it.next(alloc)).?; diff --git a/src/font/shaper/run.zig b/src/font/shaper/run.zig index a0080d1fc..85c5c410b 100644 --- a/src/font/shaper/run.zig +++ b/src/font/shaper/run.zig @@ -45,273 +45,6 @@ pub const RunIterator = struct { i: usize = 0, pub fn next(self: *RunIterator, alloc: Allocator) !?TextRun { - if (self.opts.cells.len > 0) return try self.next2(alloc); - - const cells = self.opts.row.cells(.all); - - // Trim the right side of a row that might be empty - const max: usize = max: { - for (0..cells.len) |i| { - const rev_i = cells.len - i - 1; - if (!cells[rev_i].isEmpty()) break :max rev_i + 1; - } - - break :max 0; - }; - - // Invisible cells don't have any glyphs rendered, - // so we explicitly skip them in the shaping process. - while (self.i < max and - self.opts.row.style(&cells[self.i]).flags.invisible) - { - self.i += 1; - } - - // We're over at the max - if (self.i >= max) return null; - - // Track the font for our current run - var current_font: font.Collection.Index = .{}; - - // Allow the hook to prepare - try self.hooks.prepare(); - - // Initialize our hash for this run. - var hasher = Hasher.init(0); - - // Let's get our style that we'll expect for the run. - const style = self.opts.row.style(&cells[self.i]); - - // Go through cell by cell and accumulate while we build our run. - var j: usize = self.i; - while (j < max) : (j += 1) { - // Use relative cluster positions (offset from run start) to make - // the shaping cache position-independent. This ensures that runs - // with identical content but different starting positions in the - // row produce the same hash, enabling cache reuse. - const cluster = j - self.i; - const cell = &cells[j]; - - // If we have a selection and we're at a boundary point, then - // we break the run here. - if (self.opts.selection) |unordered_sel| { - if (j > self.i) { - const sel = unordered_sel.ordered(self.opts.screen, .forward); - const start_x = sel.start().x; - const end_x = sel.end().x; - - if (start_x > 0 and - j == start_x) break; - - if (end_x > 0 and - j == end_x + 1) break; - } - } - - // If we're a spacer, then we ignore it - switch (cell.wide) { - .narrow, .wide => {}, - .spacer_head, .spacer_tail => continue, - } - - // If our cell attributes are changing, then we split the run. - // This prevents a single glyph for ">=" to be rendered with - // one color when the two components have different styling. - if (j > self.i) style: { - const prev_cell = cells[j - 1]; - - // If the prev cell and this cell are both plain - // codepoints then we check if they are commonly "bad" - // ligatures and spit the run if they are. - if (prev_cell.content_tag == .codepoint and - cell.content_tag == .codepoint) - { - const prev_cp = prev_cell.codepoint(); - switch (prev_cp) { - // fl, fi - 'f' => { - const cp = cell.codepoint(); - if (cp == 'l' or cp == 'i') break; - }, - - // st - 's' => { - const cp = cell.codepoint(); - if (cp == 't') break; - }, - - else => {}, - } - } - - // If the style is exactly the change then fast path out. - if (prev_cell.style_id == cell.style_id) break :style; - - // The style is different. We allow differing background - // styles but any other change results in a new run. - const c1 = comparableStyle(style); - const c2 = comparableStyle(self.opts.row.style(&cells[j])); - if (!c1.eql(c2)) break; - } - - // Text runs break when font styles change so we need to get - // the proper style. - const font_style: font.Style = style: { - if (style.flags.bold) { - if (style.flags.italic) break :style .bold_italic; - break :style .bold; - } - - if (style.flags.italic) break :style .italic; - break :style .regular; - }; - - // Determine the presentation format for this glyph. - const presentation: ?font.Presentation = if (cell.hasGrapheme()) p: { - // We only check the FIRST codepoint because I believe the - // presentation format must be directly adjacent to the codepoint. - const cps = self.opts.row.grapheme(cell) orelse break :p null; - assert(cps.len > 0); - if (cps[0] == 0xFE0E) break :p .text; - if (cps[0] == 0xFE0F) break :p .emoji; - break :p null; - } else emoji: { - // If we're not a grapheme, our individual char could be - // an emoji so we want to check if we expect emoji presentation. - // The font grid indexForCodepoint we use below will do this - // automatically. - break :emoji null; - }; - - // If our cursor is on this line then we break the run around the - // cursor. This means that any row with a cursor has at least - // three breaks: before, exactly the cursor, and after. - // - // We do not break a cell that is exactly the grapheme. If there - // are cells following that contain joiners, we allow those to - // break. This creates an effect where hovering over an emoji - // such as a skin-tone emoji is fine, but hovering over the - // joiners will show the joiners allowing you to modify the - // emoji. - if (!cell.hasGrapheme()) { - if (self.opts.cursor_x) |cursor_x| { - // Exactly: self.i is the cursor and we iterated once. This - // means that we started exactly at the cursor and did at - // exactly one iteration. Why exactly one? Because we may - // start at our cursor but do many if our cursor is exactly - // on an emoji. - if (self.i == cursor_x and j == self.i + 1) break; - - // Before: up to and not including the cursor. This means - // that we started before the cursor (self.i < cursor_x) - // and j is now at the cursor meaning we haven't yet processed - // the cursor. - if (self.i < cursor_x and j == cursor_x) { - assert(j > 0); - break; - } - - // After: after the cursor. We don't need to do anything - // special, we just let the run complete. - } - } - - // We need to find a font that supports this character. If - // there are additional zero-width codepoints (to form a single - // grapheme, i.e. combining characters), we need to find a font - // that supports all of them. - const font_info: struct { - idx: font.Collection.Index, - fallback: ?u32 = null, - } = font_info: { - // If we find a font that supports this entire grapheme - // then we use that. - if (try self.indexForCell( - alloc, - cell, - font_style, - presentation, - )) |idx| break :font_info .{ .idx = idx }; - - // Otherwise we need a fallback character. Prefer the - // official replacement character. - if (try self.opts.grid.getIndex( - alloc, - 0xFFFD, // replacement char - font_style, - presentation, - )) |idx| break :font_info .{ .idx = idx, .fallback = 0xFFFD }; - - // Fallback to space - if (try self.opts.grid.getIndex( - alloc, - ' ', - font_style, - presentation, - )) |idx| break :font_info .{ .idx = idx, .fallback = ' ' }; - - // We can't render at all. This is a bug, we should always - // have a font that can render a space. - unreachable; - }; - - //log.warn("char={x} info={}", .{ cell.char, font_info }); - if (j == self.i) current_font = font_info.idx; - - // If our fonts are not equal, then we're done with our run. - if (font_info.idx != current_font) break; - - // If we're a fallback character, add that and continue; we - // don't want to add the entire grapheme. - if (font_info.fallback) |cp| { - try self.addCodepoint(&hasher, cp, @intCast(cluster)); - continue; - } - - // If we're a Kitty unicode placeholder then we add a blank. - if (cell.codepoint() == terminal.kitty.graphics.unicode.placeholder) { - try self.addCodepoint(&hasher, ' ', @intCast(cluster)); - continue; - } - - // Add all the codepoints for our grapheme - try self.addCodepoint( - &hasher, - if (cell.codepoint() == 0) ' ' else cell.codepoint(), - @intCast(cluster), - ); - if (cell.hasGrapheme()) { - const cps = self.opts.row.grapheme(cell).?; - for (cps) |cp| { - // Do not send presentation modifiers - if (cp == 0xFE0E or cp == 0xFE0F) continue; - try self.addCodepoint(&hasher, cp, @intCast(cluster)); - } - } - } - - // Finalize our buffer - try self.hooks.finalize(); - - // Add our length to the hash as an additional mechanism to avoid collisions - autoHash(&hasher, j - self.i); - - // Add our font index - autoHash(&hasher, current_font); - - // Move our cursor. Must defer since we use self.i below. - defer self.i = j; - - return TextRun{ - .hash = hasher.final(), - .offset = @intCast(self.i), - .cells = @intCast(j - self.i), - .grid = self.opts.grid, - .font_index = current_font, - }; - } - - pub fn next2(self: *RunIterator, alloc: Allocator) !?TextRun { const slice = &self.opts.cells; const cells: []const terminal.page.Cell = slice.items(.raw); const graphemes: []const []const u21 = slice.items(.grapheme); @@ -360,7 +93,7 @@ pub const RunIterator = struct { // If we have a selection and we're at a boundary point, then // we break the run here. - if (self.opts.selection2) |bounds| { + if (self.opts.selection) |bounds| { if (j > self.i) { if (bounds[0] > 0 and j == bounds[0]) break; if (bounds[1] > 0 and j == bounds[1] + 1) break; @@ -485,7 +218,7 @@ pub const RunIterator = struct { } = font_info: { // If we find a font that supports this entire grapheme // then we use that. - if (try self.indexForCell2( + if (try self.indexForCell( alloc, cell, graphemes[j], @@ -583,82 +316,6 @@ pub const RunIterator = struct { /// We look for fonts that support each individual codepoint and then /// find the common font amongst all candidates. fn indexForCell( - self: *RunIterator, - alloc: Allocator, - cell: *const terminal.Cell, - style: font.Style, - presentation: ?font.Presentation, - ) !?font.Collection.Index { - if (cell.isEmpty() or - cell.codepoint() == 0 or - cell.codepoint() == terminal.kitty.graphics.unicode.placeholder) - { - return try self.opts.grid.getIndex( - alloc, - ' ', - style, - presentation, - ); - } - - // Get the font index for the primary codepoint. - const primary_cp: u32 = cell.codepoint(); - const primary = try self.opts.grid.getIndex( - alloc, - primary_cp, - style, - presentation, - ) orelse return null; - - // Easy, and common: we aren't a multi-codepoint grapheme, so - // we just return whatever index for the cell codepoint. - if (!cell.hasGrapheme()) return primary; - - // If this is a grapheme, we need to find a font that supports - // all of the codepoints in the grapheme. - const cps = self.opts.row.grapheme(cell) orelse return primary; - var candidates: std.ArrayList(font.Collection.Index) = try .initCapacity(alloc, cps.len + 1); - defer candidates.deinit(alloc); - candidates.appendAssumeCapacity(primary); - - for (cps) |cp| { - // Ignore Emoji ZWJs - if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue; - - // Find a font that supports this codepoint. If none support this - // then the whole grapheme can't be rendered so we return null. - // - // We explicitly do not require the additional grapheme components - // to support the base presentation, since it is common for emoji - // fonts to support the base emoji with emoji presentation but not - // certain ZWJ-combined characters like the male and female signs. - const idx = try self.opts.grid.getIndex( - alloc, - cp, - style, - null, - ) orelse return null; - candidates.appendAssumeCapacity(idx); - } - - // We need to find a candidate that has ALL of our codepoints - for (candidates.items) |idx| { - if (!self.opts.grid.hasCodepoint(idx, primary_cp, presentation)) continue; - for (cps) |cp| { - // Ignore Emoji ZWJs - if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue; - if (!self.opts.grid.hasCodepoint(idx, cp, null)) break; - } else { - // If the while completed, then we have a candidate that - // supports all of our codepoints. - return idx; - } - } - - return null; - } - - fn indexForCell2( self: *RunIterator, alloc: Allocator, cell: *const terminal.Cell, diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index bc7dc0321..719b0c327 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -2414,7 +2414,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { var run_iter_opts: font.shape.RunOptions = .{ .grid = self.font_grid, .cells = cells_slice, - .selection2 = if (selection) |s| s else null, + .selection = if (selection) |s| s else null, // We want to do font shaping as long as the cursor is // visible on this viewport. @@ -2423,11 +2423,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (vp.y != y) break :cursor_x null; break :cursor_x vp.x; }, - - // Old stuff - .screen = undefined, - .row = undefined, - .selection = null, }; run_iter_opts.applyBreakConfig(self.config.font_shaping_break); var run_iter = self.font_shaper.runIterator(run_iter_opts); From 2ecaf4a595928d2d30e1361c3cd2ff4801c686d3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 20 Nov 2025 21:52:13 -0800 Subject: [PATCH 077/209] font/shaper: fix harfbuzz tests --- src/font/shaper/harfbuzz.zig | 500 +++++++++++++++++++++-------------- 1 file changed, 300 insertions(+), 200 deletions(-) diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index f255d8f11..2911e1e77 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -207,16 +207,22 @@ test "run iterator" { { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("ABCD"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("ABCD"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -225,15 +231,21 @@ test "run iterator" { // Spaces should be part of a run { - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("ABCD EFG"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("ABCD EFG"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -242,16 +254,22 @@ test "run iterator" { { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("A😃D"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("A😃D"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |_| { @@ -273,14 +291,17 @@ test "run iterator: empty cells with background set" { { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0 } }); - try screen.testWriteString("A"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + // Set red background and write A + try s.nextSlice("\x1b[48;2;255;0;0mA"); // Get our first row { - const list_cell = screen.pages.getCell(.{ .active = .{ .x = 1 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 1 } }).?; const cell = list_cell.cell; cell.* = .{ .content_tag = .bg_color_rgb, @@ -288,7 +309,7 @@ test "run iterator: empty cells with background set" { }; } { - const list_cell = screen.pages.getCell(.{ .active = .{ .x = 2 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 2 } }).?; const cell = list_cell.cell; cell.* = .{ .content_tag = .bg_color_rgb, @@ -296,12 +317,15 @@ test "run iterator: empty cells with background set" { }; } + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); { const run = (try it.next(alloc)).?; @@ -327,16 +351,22 @@ test "shape" { buf_idx += try std.unicode.utf8Encode(0x1F3FD, buf[buf_idx..]); // Medium skin tone // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -355,15 +385,21 @@ test "shape inconsolata ligs" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(">="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(">="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -378,15 +414,21 @@ test "shape inconsolata ligs" { } { - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("==="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("==="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -409,15 +451,21 @@ test "shape monaspace ligs" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("==="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("==="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -443,15 +491,21 @@ test "shape arabic forced LTR" { var testdata = try testShaperWithFont(alloc, .arabic); defer testdata.deinit(); - var screen = try terminal.Screen.init(alloc, .{ .cols = 120, .rows = 30, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(@embedFile("testdata/arabic.txt")); + var t = try terminal.Terminal.init(alloc, .{ .cols = 120, .rows = 30 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(@embedFile("testdata/arabic.txt")); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -478,15 +532,21 @@ test "shape emoji width" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("👍"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("👍"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -509,13 +569,13 @@ test "shape emoji width long" { defer testdata.deinit(); // Make a screen and add a long emoji sequence to it. - var screen = try terminal.Screen.init( + var t = try terminal.Terminal.init( alloc, .{ .cols = 30, .rows = 3 }, ); - defer screen.deinit(); + defer t.deinit(alloc); - var page = screen.pages.pages.first.?.data; + var page = t.screens.active.pages.pages.first.?.data; var row = page.getRow(1); const cell = &row.cells.ptr(page.memory)[0]; cell.* = .{ @@ -534,12 +594,15 @@ test "shape emoji width long" { graphemes[0..], ); + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 1 } }).?, + .cells = state.row_data.get(1).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -566,16 +629,22 @@ test "shape variation selector VS15" { buf_idx += try std.unicode.utf8Encode(0xFE0E, buf[buf_idx..]); // ZWJ to force text // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -601,16 +670,22 @@ test "shape variation selector VS16" { buf_idx += try std.unicode.utf8Encode(0xFE0F, buf[buf_idx..]); // ZWJ to force color // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -631,21 +706,27 @@ test "shape with empty cells in between" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init( + var t = try terminal.Terminal.init( alloc, .{ .cols = 30, .rows = 3 }, ); - defer screen.deinit(); - try screen.testWriteString("A"); - screen.cursorRight(5); - try screen.testWriteString("B"); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("A"); + try s.nextSlice("\x1b[5C"); + try s.nextSlice("B"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -672,19 +753,25 @@ test "shape Chinese characters" { buf_idx += try std.unicode.utf8Encode('a', buf[buf_idx..]); // Make a screen with some data - var screen = try terminal.Screen.init( + var t = try terminal.Terminal.init( alloc, .{ .cols = 30, .rows = 3 }, ); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -713,16 +800,22 @@ test "shape box glyphs" { buf_idx += try std.unicode.utf8Encode(0x2501, buf[buf_idx..]); // // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -746,9 +839,16 @@ test "shape selection boundary" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("a1b2c3d4e5"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("a1b2c3d4e5"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Full line selection { @@ -756,13 +856,8 @@ test "shape selection boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - .selection = terminal.Selection.init( - screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, - screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?, - false, - ), + .cells = state.row_data.get(0).cells.slice(), + .selection = .{ 0, 9 }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -778,13 +873,8 @@ test "shape selection boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - .selection = terminal.Selection.init( - screen.pages.pin(.{ .active = .{ .x = 2, .y = 0 } }).?, - screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?, - false, - ), + .cells = state.row_data.get(0).cells.slice(), + .selection = .{ 2, 9 }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -800,13 +890,8 @@ test "shape selection boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - .selection = terminal.Selection.init( - screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, - screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, - false, - ), + .cells = state.row_data.get(0).cells.slice(), + .selection = .{ 0, 3 }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -822,13 +907,8 @@ test "shape selection boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - .selection = terminal.Selection.init( - screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, - screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, - false, - ), + .cells = state.row_data.get(0).cells.slice(), + .selection = .{ 1, 3 }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -844,13 +924,8 @@ test "shape selection boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - .selection = terminal.Selection.init( - screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, - screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, - false, - ), + .cells = state.row_data.get(0).cells.slice(), + .selection = .{ 1, 1 }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -869,9 +944,16 @@ test "shape cursor boundary" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("a1b2c3d4e5"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("a1b2c3d4e5"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // No cursor is full line { @@ -879,8 +961,7 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -897,8 +978,7 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), .cursor_x = 0, }); var count: usize = 0; @@ -914,8 +994,7 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -933,8 +1012,7 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), .cursor_x = 1, }); var count: usize = 0; @@ -950,8 +1028,7 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -968,8 +1045,7 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), .cursor_x = 9, }); var count: usize = 0; @@ -985,8 +1061,7 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1006,12 +1081,19 @@ test "shape cursor boundary and colored emoji" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init( + var t = try terminal.Terminal.init( alloc, .{ .cols = 3, .rows = 10 }, ); - defer screen.deinit(); - try screen.testWriteString("👍🏼"); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("👍🏼"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // No cursor is full line { @@ -1019,8 +1101,7 @@ test "shape cursor boundary and colored emoji" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1036,8 +1117,7 @@ test "shape cursor boundary and colored emoji" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), .cursor_x = 0, }); var count: usize = 0; @@ -1052,8 +1132,7 @@ test "shape cursor boundary and colored emoji" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1067,8 +1146,7 @@ test "shape cursor boundary and colored emoji" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), .cursor_x = 1, }); var count: usize = 0; @@ -1083,8 +1161,7 @@ test "shape cursor boundary and colored emoji" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1104,15 +1181,21 @@ test "shape cell attribute change" { // Plain >= should shape into 1 run { - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(">="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(">="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1124,20 +1207,23 @@ test "shape cell attribute change" { // Bold vs regular should split { - var screen = try terminal.Screen.init( - alloc, - .{ .cols = 3, .rows = 10 }, - ); - defer screen.deinit(); - try screen.testWriteString(">"); - try screen.setAttribute(.{ .bold = {} }); - try screen.testWriteString("="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(">"); + try s.nextSlice("\x1b[1m"); + try s.nextSlice("="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1149,21 +1235,26 @@ test "shape cell attribute change" { // Changing fg color should split { - var screen = try terminal.Screen.init( - alloc, - .{ .cols = 3, .rows = 10 }, - ); - defer screen.deinit(); - try screen.setAttribute(.{ .direct_color_fg = .{ .r = 1, .g = 2, .b = 3 } }); - try screen.testWriteString(">"); - try screen.setAttribute(.{ .direct_color_fg = .{ .r = 3, .g = 2, .b = 1 } }); - try screen.testWriteString("="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + // RGB 1, 2, 3 + try s.nextSlice("\x1b[38;2;1;2;3m"); + try s.nextSlice(">"); + // RGB 3, 2, 1 + try s.nextSlice("\x1b[38;2;3;2;1m"); + try s.nextSlice("="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1175,21 +1266,26 @@ test "shape cell attribute change" { // Changing bg color should not split { - var screen = try terminal.Screen.init( - alloc, - .{ .cols = 3, .rows = 10 }, - ); - defer screen.deinit(); - try screen.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); - try screen.testWriteString(">"); - try screen.setAttribute(.{ .direct_color_bg = .{ .r = 3, .g = 2, .b = 1 } }); - try screen.testWriteString("="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + // RGB 1, 2, 3 bg + try s.nextSlice("\x1b[48;2;1;2;3m"); + try s.nextSlice(">"); + // RGB 3, 2, 1 bg + try s.nextSlice("\x1b[48;2;3;2;1m"); + try s.nextSlice("="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1201,20 +1297,24 @@ test "shape cell attribute change" { // Same bg color should not split { - var screen = try terminal.Screen.init( - alloc, - .{ .cols = 3, .rows = 10 }, - ); - defer screen.deinit(); - try screen.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); - try screen.testWriteString(">"); - try screen.testWriteString("="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + // RGB 1, 2, 3 bg + try s.nextSlice("\x1b[48;2;1;2;3m"); + try s.nextSlice(">"); + try s.nextSlice("="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { From 82f5c1a13c3066d93e46c3b8201a3f54f40b119a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Nov 2025 09:02:59 -0800 Subject: [PATCH 078/209] renderer: clear renderstate memory periodically --- src/renderer/generic.zig | 18 ++++++++++++++++++ src/terminal/render.zig | 13 +++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 719b0c327..4478599a8 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -206,6 +206,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// The render state we update per loop. terminal_state: terminal.RenderState = .empty, + /// The number of frames since the last terminal state reset. + /// We reset the terminal state after ~100,000 frames (about 10 to + /// 15 minutes at 120Hz) to prevent wasted memory buildup from + /// a large screen. + terminal_state_frame_count: usize = 0, + /// Swap chain which maintains multiple copies of the state needed to /// render a frame, so that we can start building the next frame while /// the previous frame is still being processed on the GPU. @@ -1062,6 +1068,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type { state: *renderer.State, cursor_blink_visible: bool, ) !void { + // We fully deinit and reset the terminal state every so often + // so that a particularly large terminal state doesn't cause + // the renderer to hold on to retained memory. + // + // Frame count is ~12 minutes at 120Hz. + const max_terminal_state_frame_count = 100_000; + if (self.terminal_state_frame_count >= max_terminal_state_frame_count) { + self.terminal_state.deinit(self.alloc); + self.terminal_state = .empty; + } + self.terminal_state_frame_count += 1; + // Create an arena for all our temporary allocations while rebuilding var arena = ArenaAllocator.init(self.alloc); defer arena.deinit(); diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 6325ef790..b19edf65d 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -30,6 +30,19 @@ const Terminal = @import("Terminal.zig"); /// Rather than a generic clone that tries to clone all screen state per call /// (within a region), a stateful approach that optimizes for only what a /// renderer needs to do makes more sense. +/// +/// To use this, initialize the render state to empty, then call `update` +/// on each frame to update the state to the latest terminal state. +/// +/// var state: RenderState = .empty; +/// defer state.deinit(alloc); +/// state.update(alloc, &terminal); +/// +/// Note: the render state retains as much memory as possible between updates +/// to prevent future allocations. If a very large frame is rendered once, +/// the render state will retain that much memory until deinit. To avoid +/// waste, it is recommended that the caller `deinit` and start with an +/// empty render state every so often. pub const RenderState = struct { /// The current screen dimensions. It is possible that these don't match /// the renderer's current dimensions in grid cells because resizing From 3283f57fd22238dc9408cfc8220b2f7ba1465766 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Nov 2025 16:01:19 -0800 Subject: [PATCH 079/209] lib-vt: expose RenderState API --- src/lib_vt.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 41fd1c71e..95b308aab 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -47,6 +47,7 @@ pub const PageList = terminal.PageList; pub const Parser = terminal.Parser; pub const Pin = PageList.Pin; pub const Point = point.Point; +pub const RenderState = terminal.RenderState; pub const Screen = terminal.Screen; pub const ScreenSet = terminal.ScreenSet; pub const Selection = terminal.Selection; From 6f75cc56f68a468d8095d24e2d6d1932f0c2e895 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Sat, 22 Nov 2025 16:48:23 +0100 Subject: [PATCH 080/209] macOS: Only change the icon if needed Fixes #9666 --- macos/Sources/App/macOS/AppDelegate.swift | 44 ++++++++++++++++++----- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index f83b438f7..b05351bfd 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -885,12 +885,17 @@ class AppDelegate: NSObject, NSApplication.shared.appearance = .init(ghosttyConfig: config) } - @concurrent + // Using AppIconActor to ensure this work + // happens synchronously in the background + @AppIconActor private func updateAppIcon(from config: Ghostty.Config) async { var appIcon: NSImage? + var appIconName: String? = config.macosIcon.rawValue switch (config.macosIcon) { case .official: + // Discard saved icon name + appIconName = nil break case .blueprint: appIcon = NSImage(named: "BlueprintImage")! @@ -919,10 +924,15 @@ class AppDelegate: NSObject, case .custom: if let userIcon = NSImage(contentsOfFile: config.macosCustomIcon) { appIcon = userIcon + appIconName = config.macosCustomIcon } else { appIcon = nil // Revert back to official icon if invalid location + appIconName = nil // Discard saved icon name } case .customStyle: + // Discard saved icon name + // if no valid colours were found + appIconName = nil guard let ghostColor = config.macosIconGhostColor else { break } guard let screenColors = config.macosIconScreenColor else { break } guard let icon = ColorizedGhosttyIcon( @@ -931,6 +941,24 @@ class AppDelegate: NSObject, frame: config.macosIconFrame ).makeImage() else { break } appIcon = icon + let colorStrings = ([ghostColor] + screenColors).compactMap(\.hexString) + appIconName = (colorStrings + [config.macosIconFrame.rawValue]) + .joined(separator: "_") + } + // Only change the icon if it has actually changed + // from the current one + guard UserDefaults.standard.string(forKey: "CustomGhosttyIcon") != appIconName else { +#if DEBUG + if appIcon == nil { + await MainActor.run { + // Changing the app bundle's icon will corrupt code signing. + // We only use the default blueprint icon for the dock, + // so developers don't need to clean and re-build every time. + NSApplication.shared.applicationIconImage = NSImage(named: "BlueprintImage") + } + } +#endif + return } // make it immutable, so Swift 6 won't complain let newIcon = appIcon @@ -941,16 +969,9 @@ class AppDelegate: NSObject, await MainActor.run { self.appIcon = newIcon -#if DEBUG - // if no custom icon specified, we use blueprint to distinguish from release app - NSApplication.shared.applicationIconImage = newIcon ?? NSImage(named: "BlueprintImage") - // Changing the app bundle's icon will corrupt code signing. - // We only use the default blueprint icon for the dock, - // so developers don't need to clean and re-build every time. -#else NSApplication.shared.applicationIconImage = newIcon -#endif } + UserDefaults.standard.set(appIconName, forKey: "CustomGhosttyIcon") } //MARK: - Restorable State @@ -1229,3 +1250,8 @@ extension AppDelegate: NSMenuItemValidation { } } } + +@globalActor +fileprivate actor AppIconActor: GlobalActor { + static let shared = AppIconActor() +} From df466f3c7310eadcfdcbc899d54aea9abec8f444 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 22 Nov 2025 14:19:25 -0800 Subject: [PATCH 081/209] renderer: make cursorStyle depend on RenderState This makes `cursorStyle` utilize `RenderState` to determine the appropriate cursor style. This moves the cursor style logic outside the critical area, although it was cheap to begin with. This always removes `viewport_is_bottom` which had no practical use. --- src/renderer/cursor.zig | 109 +++++++++++++++++++-------------------- src/renderer/generic.zig | 19 ++----- src/terminal/render.zig | 28 +++++++--- 3 files changed, 79 insertions(+), 77 deletions(-) diff --git a/src/renderer/cursor.zig b/src/renderer/cursor.zig index ee79ead29..bfa92f31d 100644 --- a/src/renderer/cursor.zig +++ b/src/renderer/cursor.zig @@ -1,6 +1,5 @@ const std = @import("std"); const terminal = @import("../terminal/main.zig"); -const State = @import("State.zig"); /// Available cursor styles for drawing that renderers must support. /// This is a superset of terminal cursor styles since the renderer supports @@ -26,64 +25,65 @@ pub const Style = enum { } }; +pub const StyleOptions = struct { + preedit: bool = false, + focused: bool = false, + blink_visible: bool = false, +}; + /// Returns the cursor style to use for the current render state or null /// if a cursor should not be rendered at all. pub fn style( - state: *State, - focused: bool, - blink_visible: bool, + state: *const terminal.RenderState, + opts: StyleOptions, ) ?Style { // Note the order of conditionals below is important. It represents // a priority system of how we determine what state overrides cursor // visibility and style. - // The cursor is only at the bottom of the viewport. If we aren't - // at the bottom, we never render the cursor. The cursor x/y is by - // viewport so if we are above the viewport, we'll end up rendering - // the cursor in some random part of the screen. - if (!state.terminal.screens.active.viewportIsBottom()) return null; + // The cursor must be visible in the viewport to be rendered. + if (state.cursor.viewport == null) return null; // If we are in preedit, then we always show the block cursor. We do // this even if the cursor is explicitly not visible because it shows // an important editing state to the user. - if (state.preedit != null) return .block; + if (opts.preedit) return .block; + + // If we're at a password input its always a lock. + if (state.cursor.password_input) return .lock; // If the cursor is explicitly not visible by terminal mode, we don't render. - if (!state.terminal.modes.get(.cursor_visible)) return null; + if (!state.cursor.visible) return null; // If we're not focused, our cursor is always visible so that // we can show the hollow box. - if (!focused) return .block_hollow; + if (!opts.focused) return .block_hollow; // If the cursor is blinking and our blink state is not visible, // then we don't show the cursor. - if (state.terminal.modes.get(.cursor_blinking) and !blink_visible) { - return null; - } + if (state.cursor.blinking and !opts.blink_visible) return null; // Otherwise, we use whatever style the terminal wants. - return .fromTerminal(state.terminal.screens.active.cursor.cursor_style); + return .fromTerminal(state.cursor.visual_style); } test "cursor: default uses configured style" { const testing = std.testing; const alloc = testing.allocator; - var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); + var term: terminal.Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 }); defer term.deinit(alloc); term.screens.active.cursor.cursor_style = .bar; term.modes.set(.cursor_blinking, true); - var state: State = .{ - .mutex = undefined, - .terminal = &term, - .preedit = null, - }; + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &term); - try testing.expect(style(&state, true, true) == .bar); - try testing.expect(style(&state, false, true) == .block_hollow); - try testing.expect(style(&state, false, false) == .block_hollow); - try testing.expect(style(&state, true, false) == null); + try testing.expect(style(&state, .{ .preedit = false, .focused = true, .blink_visible = true }) == .bar); + try testing.expect(style(&state, .{ .preedit = false, .focused = false, .blink_visible = true }) == .block_hollow); + try testing.expect(style(&state, .{ .preedit = false, .focused = false, .blink_visible = false }) == .block_hollow); + try testing.expect(style(&state, .{ .preedit = false, .focused = true, .blink_visible = false }) == null); } test "cursor: blinking disabled" { @@ -95,16 +95,14 @@ test "cursor: blinking disabled" { term.screens.active.cursor.cursor_style = .bar; term.modes.set(.cursor_blinking, false); - var state: State = .{ - .mutex = undefined, - .terminal = &term, - .preedit = null, - }; + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &term); - try testing.expect(style(&state, true, true) == .bar); - try testing.expect(style(&state, true, false) == .bar); - try testing.expect(style(&state, false, true) == .block_hollow); - try testing.expect(style(&state, false, false) == .block_hollow); + try testing.expect(style(&state, .{ .focused = true, .blink_visible = true }) == .bar); + try testing.expect(style(&state, .{ .focused = true, .blink_visible = false }) == .bar); + try testing.expect(style(&state, .{ .focused = false, .blink_visible = true }) == .block_hollow); + try testing.expect(style(&state, .{ .focused = false, .blink_visible = false }) == .block_hollow); } test "cursor: explicitly not visible" { @@ -117,16 +115,14 @@ test "cursor: explicitly not visible" { term.modes.set(.cursor_visible, false); term.modes.set(.cursor_blinking, false); - var state: State = .{ - .mutex = undefined, - .terminal = &term, - .preedit = null, - }; + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &term); - try testing.expect(style(&state, true, true) == null); - try testing.expect(style(&state, true, false) == null); - try testing.expect(style(&state, false, true) == null); - try testing.expect(style(&state, false, false) == null); + try testing.expect(style(&state, .{ .focused = true, .blink_visible = true }) == null); + try testing.expect(style(&state, .{ .focused = true, .blink_visible = false }) == null); + try testing.expect(style(&state, .{ .focused = false, .blink_visible = true }) == null); + try testing.expect(style(&state, .{ .focused = false, .blink_visible = false }) == null); } test "cursor: always block with preedit" { @@ -135,25 +131,24 @@ test "cursor: always block with preedit" { var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); defer term.deinit(alloc); - var state: State = .{ - .mutex = undefined, - .terminal = &term, - .preedit = .{}, - }; + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &term); // In any bool state - try testing.expect(style(&state, false, false) == .block); - try testing.expect(style(&state, true, false) == .block); - try testing.expect(style(&state, true, true) == .block); - try testing.expect(style(&state, false, true) == .block); + try testing.expect(style(&state, .{ .preedit = true, .focused = false, .blink_visible = false }) == .block); + try testing.expect(style(&state, .{ .preedit = true, .focused = true, .blink_visible = false }) == .block); + try testing.expect(style(&state, .{ .preedit = true, .focused = true, .blink_visible = true }) == .block); + try testing.expect(style(&state, .{ .preedit = true, .focused = false, .blink_visible = true }) == .block); // If we're scrolled though, then we don't show the cursor. for (0..100) |_| try term.index(); try term.scrollViewport(.{ .top = {} }); + try state.update(alloc, &term); // In any bool state - try testing.expect(style(&state, false, false) == null); - try testing.expect(style(&state, true, false) == null); - try testing.expect(style(&state, true, true) == null); - try testing.expect(style(&state, false, true) == null); + try testing.expect(style(&state, .{ .preedit = true, .focused = false, .blink_visible = false }) == null); + try testing.expect(style(&state, .{ .preedit = true, .focused = true, .blink_visible = false }) == null); + try testing.expect(style(&state, .{ .preedit = true, .focused = true, .blink_visible = true }) == null); + try testing.expect(style(&state, .{ .preedit = true, .focused = false, .blink_visible = true }) == null); } diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 4478599a8..861625351 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1090,7 +1090,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { links: terminal.RenderState.CellSet, mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, - cursor_style: ?renderer.CursorStyle, scrollbar: terminal.Scrollbar, }; @@ -1122,19 +1121,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // cross-thread mailbox message within the IO path. const scrollbar = state.terminal.screens.active.pages.scrollbar(); - // Whether to draw our cursor or not. - const cursor_style = if (state.terminal.flags.password_input) - .lock - else - renderer.cursorStyle( - state, - self.focused, - cursor_blink_visible, - ); - // Get our preedit state const preedit: ?renderer.State.Preedit = preedit: { - if (cursor_style == null) break :preedit null; const p = state.preedit orelse break :preedit null; break :preedit try p.clone(arena_alloc); }; @@ -1175,7 +1163,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .links = links, .mouse = state.mouse, .preedit = preedit, - .cursor_style = cursor_style, .scrollbar = scrollbar, }; }; @@ -1195,7 +1182,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Build our GPU cells try self.rebuildCells( critical.preedit, - critical.cursor_style, + renderer.cursorStyle(&self.terminal_state, .{ + .preedit = critical.preedit != null, + .focused = self.focused, + .blink_visible = cursor_blink_visible, + }), &critical.links, ); diff --git a/src/terminal/render.zig b/src/terminal/render.zig index b19edf65d..86b299d72 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -4,6 +4,7 @@ const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const fastmem = @import("../fastmem.zig"); const color = @import("color.zig"); +const cursor = @import("cursor.zig"); const point = @import("point.zig"); const size = @import("size.zig"); const page = @import("page.zig"); @@ -56,10 +57,6 @@ pub const RenderState = struct { rows: size.CellCountInt, cols: size.CellCountInt, - /// The viewport is at the bottom of the terminal, viewing the active - /// area and scrolling with new output. - viewport_is_bottom: bool, - /// The color state for the terminal. colors: Colors, @@ -96,7 +93,6 @@ pub const RenderState = struct { pub const empty: RenderState = .{ .rows = 0, .cols = 0, - .viewport_is_bottom = false, .colors = .{ .background = .{}, .foreground = .{}, @@ -108,6 +104,10 @@ pub const RenderState = struct { .viewport = null, .cell = .{}, .style = undefined, + .visual_style = .block, + .password_input = false, + .visible = true, + .blinking = false, }, .row_data = .empty, .dirty = .false, @@ -140,6 +140,19 @@ pub const RenderState = struct { /// The style, always valid even if the cell is default style. style: Style, + /// The visual style of the cursor itself, such as a block or + /// bar. + visual_style: cursor.Style, + + /// True if the cursor is detected to be at a password input field. + password_input: bool, + + /// Cursor visibility state determined by the terminal mode. + visible: bool, + + /// Cursor blink state determined by the terminal mode. + blinking: bool, + pub const Viewport = struct { /// The x/y position of the cursor within the viewport. x: size.CellCountInt, @@ -279,11 +292,14 @@ pub const RenderState = struct { // Always set our cheap fields, its more expensive to compare self.rows = s.pages.rows; self.cols = s.pages.cols; - self.viewport_is_bottom = s.viewportIsBottom(); self.viewport_pin = viewport_pin; self.cursor.active = .{ .x = s.cursor.x, .y = s.cursor.y }; self.cursor.cell = s.cursor.page_cell.*; self.cursor.style = s.cursor.style; + self.cursor.visual_style = s.cursor.cursor_style; + self.cursor.password_input = t.flags.password_input; + self.cursor.visible = t.modes.get(.cursor_visible); + self.cursor.blinking = t.modes.get(.cursor_blinking); // Always reset the cursor viewport position. In the future we can // probably cache this by comparing the cursor pin and viewport pin From 92aa96038137ef4f88a04e5a30b9c65405d8f835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Soares?= Date: Sun, 23 Nov 2025 12:43:11 -0300 Subject: [PATCH 082/209] Add flag for quick terminal --- .../Features/QuickTerminal/QuickTerminalController.swift | 5 ++++- src/apprt/gtk/class/surface.zig | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index b3ad88666..4c2052f23 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -342,7 +342,10 @@ class QuickTerminalController: BaseTerminalController { // animate out. if surfaceTree.isEmpty, let ghostty_app = ghostty.app { - let view = Ghostty.SurfaceView(ghostty_app, baseConfig: nil) + var config = Ghostty.SurfaceConfiguration() + config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1" + + let view = Ghostty.SurfaceView(ghostty_app, baseConfig: config) surfaceTree = SplitTree(view: view) focusedSurface = view } diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 291a405ce..3f9c0d741 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1465,6 +1465,10 @@ pub const Surface = extern struct { // EnvMap is a bit annoying so I'm punting it. if (ext.getAncestor(Window, self.as(gtk.Widget))) |window| { try window.winproto().addSubprocessEnv(&env); + + if (window.isQuickTerminal()) { + try env.put("GHOSTTY_QUICK_TERMINAL", "1"); + } } return env; From 97926ca30735c4b6dd606f8bce3802380231b019 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Sat, 22 Nov 2025 14:16:58 -0500 Subject: [PATCH 083/209] Update uucode to the latest, for future width and grapheme break changes --- build.zig.zon | 5 ++--- src/build/uucode_config.zig | 24 +++++++++++------------- src/unicode/props_uucode.zig | 7 ++----- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index dfccaf61d..0d708fc8d 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -38,9 +38,8 @@ .lazy = true, }, .uucode = .{ - // TODO: currently the use-llvm branch because its broken on self-hosted - .url = "https://github.com/jacobsandlund/uucode/archive/b309dfb4e25a38201d7b300b201a698e02283862.tar.gz", - .hash = "uucode-0.1.0-ZZjBPl_mQwDHQowHOzXwgTPhAqcFekfYpAeUfeHZNCl3", + .url = "https://github.com/jacobsandlund/uucode/archive/4f474cf311877701d9f09b415b1aff11df30e3b5.tar.gz", + .hash = "uucode-0.1.0-ZZjBPu4HTQAN0P6B0WxKHM2ugS2adGDWk3hW8i9L8ufw", }, .zig_wayland = .{ // codeberg ifreund/zig-wayland diff --git a/src/build/uucode_config.zig b/src/build/uucode_config.zig index 277e3cb49..0843732b1 100644 --- a/src/build/uucode_config.zig +++ b/src/build/uucode_config.zig @@ -19,23 +19,23 @@ fn computeWidth( _ = backing; _ = tracking; - // Emoji modifiers are technically width 0 because they're joining - // points. But we handle joining via grapheme break and don't use width - // there. If a emoji modifier is standalone, we want it to take up - // two columns. - if (data.is_emoji_modifier) { - assert(data.wcwidth == 0); - data.wcwidth = 2; - return; + // This condition is to get the previous behavior of uucode's `wcwidth`, + // returning the width of a code point in a grapheme cluster but with the + // exception to treat emoji modifiers as width 2 so they can be displayed + // in isolation. PRs immediately to follow will take advantage of the new + // uucode `wcwidt_standalone` vs `wcwidth_zero_in_grapheme` split. + if (data.wcwidth_zero_in_grapheme and !data.is_emoji_modifier) { + data.width = 0; + } else { + data.width = @min(2, data.wcwidth_standalone); } - - data.width = @intCast(@min(2, @max(0, data.wcwidth))); } const width = config.Extension{ .inputs = &.{ + "wcwidth_standalone", + "wcwidth_zero_in_grapheme", "is_emoji_modifier", - "wcwidth", }, .compute = &computeWidth, .fields = &.{ @@ -90,8 +90,6 @@ pub const tables = [_]config.Table{ width.field("width"), d.field("grapheme_break"), is_symbol.field("is_symbol"), - d.field("is_emoji_modifier"), - d.field("is_emoji_modifier_base"), d.field("is_emoji_vs_text"), d.field("is_emoji_vs_emoji"), }, diff --git a/src/unicode/props_uucode.zig b/src/unicode/props_uucode.zig index 84aafd0be..b30c4be3a 100644 --- a/src/unicode/props_uucode.zig +++ b/src/unicode/props_uucode.zig @@ -11,11 +11,6 @@ const GraphemeBoundaryClass = @import("props.zig").GraphemeBoundaryClass; fn graphemeBoundaryClass(cp: u21) GraphemeBoundaryClass { if (cp > uucode.config.max_code_point) return .invalid; - // We special-case modifier bases because we should not break - // if a modifier isn't next to a base. - if (uucode.get(.is_emoji_modifier, cp)) return .emoji_modifier; - if (uucode.get(.is_emoji_modifier_base, cp)) return .extended_pictographic_base; - return switch (uucode.get(.grapheme_break, cp)) { .extended_pictographic => .extended_pictographic, .l => .L, @@ -27,6 +22,8 @@ fn graphemeBoundaryClass(cp: u21) GraphemeBoundaryClass { .zwj => .zwj, .spacing_mark => .spacing_mark, .regional_indicator => .regional_indicator, + .emoji_modifier => .emoji_modifier, + .emoji_modifier_base => .extended_pictographic_base, .zwnj, .indic_conjunct_break_extend, From 6588e1e9e7acf32a5a631f9ffa05dd6686203e16 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 00:08:15 +0000 Subject: [PATCH 084/209] build(deps): bump peter-evans/create-pull-request from 7.0.8 to 7.0.9 Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 7.0.8 to 7.0.9. - [Release notes](https://github.com/peter-evans/create-pull-request/releases) - [Commits](https://github.com/peter-evans/create-pull-request/compare/271a8d0340265f705b14b6d32b9829c1cb33d45e...84ae59a2cdc2258d6fa0732dd66352dddae2a412) --- updated-dependencies: - dependency-name: peter-evans/create-pull-request dependency-version: 7.0.9 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/update-colorschemes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 595d5f1f2..b641c0bc9 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -62,7 +62,7 @@ jobs: run: nix build .#ghostty - name: Create pull request - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 with: title: Update iTerm2 colorschemes base: main From 36c32958068c54879adfdd389f8146193f9b0e92 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Sun, 23 Nov 2025 20:39:35 -0500 Subject: [PATCH 085/209] unicode: don't narrow invalid text presentation (VS15) sequences --- src/build/uucode_config.zig | 3 +- src/terminal/Terminal.zig | 92 +++++++++++++++++++++++++++++------- src/unicode/props.zig | 12 ++--- src/unicode/props_uucode.zig | 6 +-- 4 files changed, 82 insertions(+), 31 deletions(-) diff --git a/src/build/uucode_config.zig b/src/build/uucode_config.zig index 0843732b1..fcad6ad6a 100644 --- a/src/build/uucode_config.zig +++ b/src/build/uucode_config.zig @@ -90,8 +90,7 @@ pub const tables = [_]config.Table{ width.field("width"), d.field("grapheme_break"), is_symbol.field("is_symbol"), - d.field("is_emoji_vs_text"), - d.field("is_emoji_vs_emoji"), + d.field("is_emoji_vs_base"), }, }, }; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 1ec5b5d47..09e3727df 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -374,20 +374,10 @@ pub fn print(self: *Terminal, c: u21) !void { // the cell width accordingly. VS16 makes the character wide and // VS15 makes it narrow. if (c == 0xFE0F or c == 0xFE0E) { - // This check below isn't robust enough to be correct. - // But it is correct enough (the emoji check alone served us - // well through Ghostty 1.2.3!) and we can fix it up later. - - // Emoji always allow VS15/16 const prev_props = unicode.table.get(prev.cell.content.codepoint); - const emoji = prev_props.grapheme_boundary_class.isExtendedPictographic(); - if (!emoji) valid_check: { - // If not an emoji, check if it is a defined variation - // sequence in emoji-variation-sequences.txt - if (c == 0xFE0F and prev_props.emoji_vs_emoji) break :valid_check; - if (c == 0xFE0E and prev_props.emoji_vs_text) break :valid_check; - return; - } + // Check if it is a valid variation sequence in + // emoji-variation-sequences.txt, and if not, ignore the char. + if (!prev_props.emoji_vs_base) return; switch (c) { 0xFE0F => wide: { @@ -3288,7 +3278,7 @@ test "Terminal: print invalid VS16 non-grapheme" { try t.print('x'); try t.print(0xFE0F); - // We should have 2 cells taken up. It is one character but "wide". + // We should have 1 narrow cell. try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); @@ -3601,6 +3591,40 @@ test "Terminal: VS15 on already narrow emoji" { } } +test "Terminal: print invalid VS15 in emoji ZWJ sequence" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print('\u{1F469}'); // 👩 + try t.print(0xFE0E); // not valid with U+1F469 as base + try t.print('\u{200D}'); // ZWJ + try t.print('\u{1F466}'); // 👦 + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, '\u{1F469}'), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{ '\u{200D}', '\u{1F466}' }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + test "Terminal: VS15 to make narrow character with pending wrap" { var t = try init(testing.allocator, .{ .rows = 5, .cols = 2 }); defer t.deinit(testing.allocator); @@ -3723,9 +3747,9 @@ test "Terminal: print invalid VS16 grapheme" { // https://github.com/mitchellh/ghostty/issues/1482 try t.print('x'); - try t.print(0xFE0F); + try t.print(0xFE0F); // invalid VS16 - // We should have 2 cells taken up. It is one character but "wide". + // We should have 1 cells taken up, and narrow. try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); @@ -3758,7 +3782,7 @@ test "Terminal: print invalid VS16 with second char" { try t.print(0xFE0F); try t.print('y'); - // We should have 2 cells taken up. It is one character but "wide". + // We should have 2 cells taken up, from two separate narrow characters. try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); @@ -3781,6 +3805,40 @@ test "Terminal: print invalid VS16 with second char" { } } +test "Terminal: print invalid VS16 with second char (combining)" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // https://github.com/mitchellh/ghostty/issues/1482 + try t.print('n'); + try t.print(0xFE0F); // invalid VS16 + try t.print(0x0303); // combining tilde + + // We should have 1 cells taken up, and narrow. + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'n'), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{'\u{0303}'}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + test "Terminal: overwrite grapheme should clear grapheme data" { var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); diff --git a/src/unicode/props.zig b/src/unicode/props.zig index 7099e79cd..492dad34a 100644 --- a/src/unicode/props.zig +++ b/src/unicode/props.zig @@ -16,15 +16,13 @@ pub const Properties = packed struct { grapheme_boundary_class: GraphemeBoundaryClass = .invalid, /// Emoji VS compatibility - emoji_vs_text: bool = false, - emoji_vs_emoji: bool = false, + emoji_vs_base: bool = false, // Needed for lut.Generator pub fn eql(a: Properties, b: Properties) bool { return a.width == b.width and a.grapheme_boundary_class == b.grapheme_boundary_class and - a.emoji_vs_text == b.emoji_vs_text and - a.emoji_vs_emoji == b.emoji_vs_emoji; + a.emoji_vs_base == b.emoji_vs_base; } // Needed for lut.Generator @@ -36,14 +34,12 @@ pub const Properties = packed struct { \\.{{ \\ .width= {}, \\ .grapheme_boundary_class= .{s}, - \\ .emoji_vs_text= {}, - \\ .emoji_vs_emoji= {}, + \\ .emoji_vs_base= {}, \\}} , .{ self.width, @tagName(self.grapheme_boundary_class), - self.emoji_vs_text, - self.emoji_vs_emoji, + self.emoji_vs_base, }); } }; diff --git a/src/unicode/props_uucode.zig b/src/unicode/props_uucode.zig index b30c4be3a..2440d437c 100644 --- a/src/unicode/props_uucode.zig +++ b/src/unicode/props_uucode.zig @@ -48,15 +48,13 @@ pub fn get(cp: u21) Properties { if (cp > uucode.config.max_code_point) return .{ .width = 1, .grapheme_boundary_class = .invalid, - .emoji_vs_text = false, - .emoji_vs_emoji = false, + .emoji_vs_base = false, }; return .{ .width = uucode.get(.width, cp), .grapheme_boundary_class = graphemeBoundaryClass(cp), - .emoji_vs_text = uucode.get(.is_emoji_vs_text, cp), - .emoji_vs_emoji = uucode.get(.is_emoji_vs_emoji, cp), + .emoji_vs_base = uucode.get(.is_emoji_vs_base, cp), }; } From 62ec34072fd09ce0293d8467b40954da2475f3ee Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Sun, 23 Nov 2025 22:56:00 -0500 Subject: [PATCH 086/209] fix typo --- src/build/uucode_config.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/build/uucode_config.zig b/src/build/uucode_config.zig index 0843732b1..62339c375 100644 --- a/src/build/uucode_config.zig +++ b/src/build/uucode_config.zig @@ -23,7 +23,7 @@ fn computeWidth( // returning the width of a code point in a grapheme cluster but with the // exception to treat emoji modifiers as width 2 so they can be displayed // in isolation. PRs immediately to follow will take advantage of the new - // uucode `wcwidt_standalone` vs `wcwidth_zero_in_grapheme` split. + // uucode `wcwidth_standalone` vs `wcwidth_zero_in_grapheme` split. if (data.wcwidth_zero_in_grapheme and !data.is_emoji_modifier) { data.width = 0; } else { From 6e0e1d138801b48ba56d98bf4bdb1ebc714f0e75 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Sun, 23 Nov 2025 23:05:03 -0500 Subject: [PATCH 087/209] update uucode to latest --- build.zig.zon | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 0d708fc8d..cd37e95c0 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -38,8 +38,8 @@ .lazy = true, }, .uucode = .{ - .url = "https://github.com/jacobsandlund/uucode/archive/4f474cf311877701d9f09b415b1aff11df30e3b5.tar.gz", - .hash = "uucode-0.1.0-ZZjBPu4HTQAN0P6B0WxKHM2ugS2adGDWk3hW8i9L8ufw", + .url = "https://github.com/jacobsandlund/uucode/archive/4c9e11de7c7648b3f1e131206e60a3f9cbe2fde6.tar.gz", + .hash = "uucode-0.1.0-ZZjBPqkITQC_aamLUUlzRuzpzSAfUe8g4ykzeYJkbnhC", }, .zig_wayland = .{ // codeberg ifreund/zig-wayland From 2b6c3092179d5beee8f458b417baa8d9bb90df4b Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Mon, 24 Nov 2025 08:29:27 -0500 Subject: [PATCH 088/209] Update uucode to latest --- build.zig.zon | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index cd37e95c0..fc7d855f4 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -38,8 +38,9 @@ .lazy = true, }, .uucode = .{ - .url = "https://github.com/jacobsandlund/uucode/archive/4c9e11de7c7648b3f1e131206e60a3f9cbe2fde6.tar.gz", - .hash = "uucode-0.1.0-ZZjBPqkITQC_aamLUUlzRuzpzSAfUe8g4ykzeYJkbnhC", + // jacobsandlund/uucode + .url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + .hash = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", }, .zig_wayland = .{ // codeberg ifreund/zig-wayland From 808d31f6eea96e7c5f495c020ed3ddb023f9dde5 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Mon, 24 Nov 2025 09:13:19 -0500 Subject: [PATCH 089/209] nix cache --update --- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/build.zig.zon.json b/build.zig.zon.json index cd2621b2e..6de71dd82 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -114,10 +114,10 @@ "url": "git+https://github.com/jacobsandlund/uucode#5f05f8f83a75caea201f12cc8ea32a2d82ea9732", "hash": "sha256-sHPh+TQSdUGus/QTbj7KSJJkTuNTrK4VNmQDjS30Lf8=" }, - "uucode-0.1.0-ZZjBPl_mQwDHQowHOzXwgTPhAqcFekfYpAeUfeHZNCl3": { + "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E": { "name": "uucode", - "url": "https://github.com/jacobsandlund/uucode/archive/b309dfb4e25a38201d7b300b201a698e02283862.tar.gz", - "hash": "sha256-jvko1MdWr1OG4P58KjdB1JMnWy4EbrO3xIkV8fiQkC0=" + "url": "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + "hash": "sha256-SzpYGhgG4B6Luf8eT35sKLobCxjmwEuo1Twk0jeu9g4=" }, "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS": { "name": "vaxis", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index c38504847..ae227129b 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -267,11 +267,11 @@ in }; } { - name = "uucode-0.1.0-ZZjBPl_mQwDHQowHOzXwgTPhAqcFekfYpAeUfeHZNCl3"; + name = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E"; path = fetchZigArtifact { name = "uucode"; - url = "https://github.com/jacobsandlund/uucode/archive/b309dfb4e25a38201d7b300b201a698e02283862.tar.gz"; - hash = "sha256-jvko1MdWr1OG4P58KjdB1JMnWy4EbrO3xIkV8fiQkC0="; + url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz"; + hash = "sha256-SzpYGhgG4B6Luf8eT35sKLobCxjmwEuo1Twk0jeu9g4="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 6bd86a206..c7a5bae21 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -28,7 +28,7 @@ https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz -https://github.com/jacobsandlund/uucode/archive/b309dfb4e25a38201d7b300b201a698e02283862.tar.gz +https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251110-150531-d5f3d53/ghostty-themes.tgz https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 8ed18e38b..5a64f81a8 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -139,9 +139,9 @@ }, { "type": "archive", - "url": "https://github.com/jacobsandlund/uucode/archive/b309dfb4e25a38201d7b300b201a698e02283862.tar.gz", - "dest": "vendor/p/uucode-0.1.0-ZZjBPl_mQwDHQowHOzXwgTPhAqcFekfYpAeUfeHZNCl3", - "sha256": "8ef928d4c756af5386e0fe7c2a3741d493275b2e046eb3b7c48915f1f890902d" + "url": "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + "dest": "vendor/p/uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", + "sha256": "4b3a581a1806e01e8bb9ff1e4f7e6c28ba1b0b18e6c04ba8d53c24d237aef60e" }, { "type": "archive", From 61c73814524f5bc4e1e5e0ba66e28a57a9281679 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Mon, 24 Nov 2025 09:14:03 -0500 Subject: [PATCH 090/209] Update comment. PR for wcwidth_standalone might be a bit --- src/build/uucode_config.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/build/uucode_config.zig b/src/build/uucode_config.zig index 62339c375..c96e0f20b 100644 --- a/src/build/uucode_config.zig +++ b/src/build/uucode_config.zig @@ -22,8 +22,8 @@ fn computeWidth( // This condition is to get the previous behavior of uucode's `wcwidth`, // returning the width of a code point in a grapheme cluster but with the // exception to treat emoji modifiers as width 2 so they can be displayed - // in isolation. PRs immediately to follow will take advantage of the new - // uucode `wcwidth_standalone` vs `wcwidth_zero_in_grapheme` split. + // in isolation. PRs to follow will take advantage of the new uucode + // `wcwidth_standalone` vs `wcwidth_zero_in_grapheme` split. if (data.wcwidth_zero_in_grapheme and !data.is_emoji_modifier) { data.width = 0; } else { From 8f033c7022ef36b9a5bed6508ee245bc38b0d072 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Mon, 24 Nov 2025 09:25:39 -0500 Subject: [PATCH 091/209] Add test with just a single emoji followed by VS15 (invalid) --- src/terminal/Terminal.zig | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 09e3727df..e75fd731a 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -3591,6 +3591,37 @@ test "Terminal: VS15 on already narrow emoji" { } } +test "Terminal: print invalid VS15 following emoji is wide" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print('\u{1F9E0}'); // 🧠 + try t.print(0xFE0E); // not valid with U+1F9E0 as base + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, '\u{1F9E0}'), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + test "Terminal: print invalid VS15 in emoji ZWJ sequence" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); From d4c2376c2d450527040b7e740f3e806a98beb161 Mon Sep 17 00:00:00 2001 From: Pyry Takala <7336413+pyrytakala@users.noreply.github.com> Date: Mon, 24 Nov 2025 20:34:07 +0000 Subject: [PATCH 092/209] Fix LangSet.hasLang() to compare against FcLangEqual instead of FcTrue FcLangSetHasLang returns FcLangResult enum values: - FcLangEqual (0): Exact match - FcLangDifferentTerritory (1): Same language, different territory - FcLangDifferentLang (2): Different language The previous comparison to FcTrue (1) caused: - Exact matches (0) to incorrectly return false - Partial matches (1) to incorrectly return true This fix changes the comparison to FcLangEqual (0) so hasLang() correctly returns true only for exact language matches. Fixes emoji font detection which relies on checking for 'und-zsye' language tag support. --- pkg/fontconfig/lang_set.zig | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/pkg/fontconfig/lang_set.zig b/pkg/fontconfig/lang_set.zig index aaf55bab6..abefcc3e6 100644 --- a/pkg/fontconfig/lang_set.zig +++ b/pkg/fontconfig/lang_set.zig @@ -11,8 +11,12 @@ pub const LangSet = opaque { c.FcLangSetDestroy(self.cval()); } + pub fn addLang(self: *LangSet, lang: [:0]const u8) bool { + return c.FcLangSetAdd(self.cval(), lang.ptr) == c.FcTrue; + } + pub fn hasLang(self: *const LangSet, lang: [:0]const u8) bool { - return c.FcLangSetHasLang(self.cvalConst(), lang.ptr) == c.FcTrue; + return c.FcLangSetHasLang(self.cvalConst(), lang.ptr) == c.FcLangEqual; } pub inline fn cval(self: *LangSet) *c.struct__FcLangSet { @@ -32,3 +36,26 @@ test "create" { try testing.expect(!fs.hasLang("und-zsye")); } + +test "hasLang exact match" { + const testing = std.testing; + + // Test exact match: langset with "en-US" should return true for "en-US" + var fs = LangSet.create(); + defer fs.destroy(); + try testing.expect(fs.addLang("en-US")); + try testing.expect(fs.hasLang("en-US")); + + // Test exact match: langset with "und-zsye" should return true for "und-zsye" + var fs_emoji = LangSet.create(); + defer fs_emoji.destroy(); + try testing.expect(fs_emoji.addLang("und-zsye")); + try testing.expect(fs_emoji.hasLang("und-zsye")); + + // Test mismatch: langset with "en-US" should return false for "fr" + try testing.expect(!fs.hasLang("fr")); + + // Test partial match: langset with "en-US" should return false for "en-GB" + // (different territory, but we only want exact matches) + try testing.expect(!fs.hasLang("en-GB")); +} From 5bfeba6603f2997a34a276eb33e49552e1926cd6 Mon Sep 17 00:00:00 2001 From: Pyry Takala <7336413+pyrytakala@users.noreply.github.com> Date: Mon, 24 Nov 2025 23:31:14 +0000 Subject: [PATCH 093/209] Fix LoadFlags struct bit alignment to match FreeType API The struct was missing padding at bit position 8, causing all subsequent flag fields (bits 9+) to be misaligned by one bit position. See: https://freetype.org/freetype2/docs/reference/ft2-glyph_retrieval.html#ft_load_xxx --- pkg/freetype/face.zig | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/pkg/freetype/face.zig b/pkg/freetype/face.zig index f8714d4fe..b639a499b 100644 --- a/pkg/freetype/face.zig +++ b/pkg/freetype/face.zig @@ -263,24 +263,25 @@ pub const LoadFlags = packed struct { force_autohint: bool = false, crop_bitmap: bool = false, pedantic: bool = false, - ignore_global_advance_with: bool = false, + _padding1: u1 = 0, + ignore_global_advance_width: bool = false, no_recurse: bool = false, ignore_transform: bool = false, monochrome: bool = false, linear_design: bool = false, + sbits_only: bool = false, no_autohint: bool = false, - _padding1: u1 = 0, target_normal: bool = false, target_light: bool = false, target_mono: bool = false, target_lcd: bool = false, - target_lcd_v: bool = false, color: bool = false, + target_lcd_v: bool = false, compute_metrics: bool = false, bitmap_metrics_only: bool = false, _padding2: u1 = 0, no_svg: bool = false, - _padding3: u7 = 0, + _padding3: u6 = 0, test { // This must always be an i32 size so we can bitcast directly. @@ -290,12 +291,19 @@ pub const LoadFlags = packed struct { test "bitcast" { const testing = std.testing; + const cval: i32 = c.FT_LOAD_RENDER | c.FT_LOAD_PEDANTIC | c.FT_LOAD_COLOR; const flags = @as(LoadFlags, @bitCast(cval)); try testing.expect(!flags.no_hinting); try testing.expect(flags.render); try testing.expect(flags.pedantic); try testing.expect(flags.color); + + // Verify bit alignment (for bit 9) + const cval2: i32 = c.FT_LOAD_IGNORE_GLOBAL_ADVANCE_WIDTH; + const flags2 = @as(LoadFlags, @bitCast(cval2)); + try testing.expect(flags2.ignore_global_advance_width); + try testing.expect(!flags2.no_recurse); } }; From 6a9c869f9dbc4b728888ef11edec86cc1b05560a Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 24 Nov 2025 17:24:32 -0700 Subject: [PATCH 094/209] Partially revert 25856d6 since it broke pkg/freetype tests --- pkg/freetype/res/FiraCode-Regular.ttf | Bin 0 -> 289624 bytes pkg/freetype/test.zig | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100755 pkg/freetype/res/FiraCode-Regular.ttf diff --git a/pkg/freetype/res/FiraCode-Regular.ttf b/pkg/freetype/res/FiraCode-Regular.ttf new file mode 100755 index 0000000000000000000000000000000000000000..bd736851948d2d76483b434113e2d9ee35554446 GIT binary patch literal 289624 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 literal 0 HcmV?d00001 diff --git a/pkg/freetype/test.zig b/pkg/freetype/test.zig index 093061616..866c6f2a4 100644 --- a/pkg/freetype/test.zig +++ b/pkg/freetype/test.zig @@ -1 +1 @@ -pub const font_regular = @embedFile("res/JetBrainsMono-Regular.ttf"); +pub const font_regular = @embedFile("res/FiraCode-Regular.ttf"); From 3cd6939af63cccacf32d11377da474f12060d594 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 24 Nov 2025 17:35:53 -0700 Subject: [PATCH 095/209] pkg/freetype: add failing unit tests for LoadFlags --- pkg/freetype/face.zig | 70 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/pkg/freetype/face.zig b/pkg/freetype/face.zig index b639a499b..e4c17cf92 100644 --- a/pkg/freetype/face.zig +++ b/pkg/freetype/face.zig @@ -254,7 +254,7 @@ pub const RenderMode = enum(c_uint) { /// A list of bit field constants for FT_Load_Glyph to indicate what kind of /// operations to perform during glyph loading. -pub const LoadFlags = packed struct { +pub const LoadFlags = packed struct(c_int) { no_scale: bool = false, no_hinting: bool = false, render: bool = false, @@ -283,28 +283,82 @@ pub const LoadFlags = packed struct { no_svg: bool = false, _padding3: u6 = 0, - test { - // This must always be an i32 size so we can bitcast directly. - const testing = std.testing; - try testing.expectEqual(@sizeOf(i32), @sizeOf(LoadFlags)); - } + pub const Target = enum(u4) { + normal = 0, + light = 1, + mono = 2, + lcd = 3, + lcd_v = 4, + }; test "bitcast" { const testing = std.testing; - + const cval: i32 = c.FT_LOAD_RENDER | c.FT_LOAD_PEDANTIC | c.FT_LOAD_COLOR; const flags = @as(LoadFlags, @bitCast(cval)); try testing.expect(!flags.no_hinting); try testing.expect(flags.render); try testing.expect(flags.pedantic); try testing.expect(flags.color); - + // Verify bit alignment (for bit 9) const cval2: i32 = c.FT_LOAD_IGNORE_GLOBAL_ADVANCE_WIDTH; const flags2 = @as(LoadFlags, @bitCast(cval2)); try testing.expect(flags2.ignore_global_advance_width); try testing.expect(!flags2.no_recurse); } + + test "all flags individually" { + const testing = std.testing; + + try testing.expectEqual( + c.FT_LOAD_DEFAULT, + @as(c_int, @bitCast(LoadFlags{})), + ); + + inline for ([_]struct { c_int, []const u8 }{ + .{ c.FT_LOAD_NO_SCALE, "no_scale" }, + .{ c.FT_LOAD_NO_HINTING, "no_hinting" }, + .{ c.FT_LOAD_RENDER, "render" }, + .{ c.FT_LOAD_NO_BITMAP, "no_bitmap" }, + .{ c.FT_LOAD_VERTICAL_LAYOUT, "vertical_layout" }, + .{ c.FT_LOAD_FORCE_AUTOHINT, "force_autohint" }, + .{ c.FT_LOAD_CROP_BITMAP, "crop_bitmap" }, + .{ c.FT_LOAD_PEDANTIC, "pedantic" }, + .{ c.FT_LOAD_ADVANCE_ONLY, "advance_only" }, + .{ c.FT_LOAD_IGNORE_GLOBAL_ADVANCE_WIDTH, "ignore_global_advance_width" }, + .{ c.FT_LOAD_NO_RECURSE, "no_recurse" }, + .{ c.FT_LOAD_IGNORE_TRANSFORM, "ignore_transform" }, + .{ c.FT_LOAD_MONOCHROME, "monochrome" }, + .{ c.FT_LOAD_LINEAR_DESIGN, "linear_design" }, + .{ c.FT_LOAD_SBITS_ONLY, "sbits_only" }, + .{ c.FT_LOAD_NO_AUTOHINT, "no_autohint" }, + .{ c.FT_LOAD_COLOR, "color" }, + .{ c.FT_LOAD_COMPUTE_METRICS, "compute_metrics" }, + .{ c.FT_LOAD_BITMAP_METRICS_ONLY, "bitmap_metrics_only" }, + .{ c.FT_LOAD_SVG_ONLY, "svg_only" }, + .{ c.FT_LOAD_NO_SVG, "no_svg" }, + }) |pair| { + var flags: LoadFlags = .{}; + @field(flags, pair[1]) = true; + try testing.expectEqual(pair[0], @as(c_int, @bitCast(flags))); + } + } + + test "all load targets" { + const testing = std.testing; + + inline for ([_]struct { c_int, Target }{ + .{ c.FT_LOAD_TARGET_NORMAL, .normal }, + .{ c.FT_LOAD_TARGET_LIGHT, .light }, + .{ c.FT_LOAD_TARGET_MONO, .mono }, + .{ c.FT_LOAD_TARGET_LCD, .lcd }, + .{ c.FT_LOAD_TARGET_LCD_V, .lcd_v }, + }) |pair| { + const flags: LoadFlags = .{ .target = pair[1] }; + try testing.expectEqual(pair[0], @as(c_int, @bitCast(flags))); + } + } }; test "loading memory font" { From 6d65abc489cc015fa4958567c86c10eb05cf09c3 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 24 Nov 2025 17:42:02 -0700 Subject: [PATCH 096/209] fix(pkg/freetype): fully correct load flags These now properly match the FreeType API- compared directly in the unit tests against the values provided by the FreeType header itself. This was ridiculously wrong before, like... wow. --- pkg/freetype/face.zig | 20 ++++++++++---------- src/font/face/freetype.zig | 14 +++++++++----- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/pkg/freetype/face.zig b/pkg/freetype/face.zig index e4c17cf92..d4f74b7ee 100644 --- a/pkg/freetype/face.zig +++ b/pkg/freetype/face.zig @@ -252,8 +252,12 @@ pub const RenderMode = enum(c_uint) { sdf = c.FT_RENDER_MODE_SDF, }; -/// A list of bit field constants for FT_Load_Glyph to indicate what kind of -/// operations to perform during glyph loading. +/// A collection of flags for FT_Load_Glyph that indicate +/// what kind of operations to perform during glyph loading. +/// +/// Some of these flags are not included in the official FreeType +/// documentation, but are nevertheless present and named in the +/// header, so the names have been copied from there. pub const LoadFlags = packed struct(c_int) { no_scale: bool = false, no_hinting: bool = false, @@ -263,7 +267,7 @@ pub const LoadFlags = packed struct(c_int) { force_autohint: bool = false, crop_bitmap: bool = false, pedantic: bool = false, - _padding1: u1 = 0, + advance_only: bool = false, ignore_global_advance_width: bool = false, no_recurse: bool = false, ignore_transform: bool = false, @@ -271,17 +275,13 @@ pub const LoadFlags = packed struct(c_int) { linear_design: bool = false, sbits_only: bool = false, no_autohint: bool = false, - target_normal: bool = false, - target_light: bool = false, - target_mono: bool = false, - target_lcd: bool = false, + target: Target = .normal, color: bool = false, - target_lcd_v: bool = false, compute_metrics: bool = false, bitmap_metrics_only: bool = false, - _padding2: u1 = 0, + svg_only: bool = false, no_svg: bool = false, - _padding3: u6 = 0, + _padding: u7 = 0, pub const Target = enum(u4) { normal = 0, diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index ced313a94..fe3dcf707 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -376,11 +376,15 @@ pub const Face = struct { // If we're gonna be rendering this glyph in monochrome, // then we should use the monochrome hinter as well, or // else it won't look very good at all. - .target_mono = self.load_flags.monochrome, - - // Otherwise we select hinter based on the `light` flag. - .target_normal = !self.load_flags.light and !self.load_flags.monochrome, - .target_light = self.load_flags.light and !self.load_flags.monochrome, + // + // Otherwise if the user asked for light hinting we + // use that, otherwise we just use the normal target. + .target = if (self.load_flags.monochrome) + .mono + else if (self.load_flags.light) + .light + else + .normal, // NO_SVG set to true because we don't currently support rendering // SVG glyphs under FreeType, since that requires bundling another From 878ccd3f3406494266c109e47a1f2a6753dcb912 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Nov 2025 19:52:14 -0800 Subject: [PATCH 097/209] renderer: use proper cell style for cursor-color/text Regression from render state work. --- src/renderer/generic.zig | 55 +++++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 861625351..025578c81 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -2782,18 +2782,34 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Setup our cursor rendering information. cursor: { - // By default, we don't handle cursor inversion on the shader. + // Clear our cursor by default. self.cells.setCursor(null, null); self.uniforms.cursor_pos = .{ std.math.maxInt(u16), std.math.maxInt(u16), }; + // If the cursor isn't visible on the viewport, don't show + // a cursor. Otherwise, get our cursor cell, because we may + // need it for styling. + const cursor_vp = state.cursor.viewport orelse break :cursor; + const cursor_style: terminal.Style = cursor_style: { + const cells = state.row_data.items(.cells); + const cell = cells[cursor_vp.y].get(cursor_vp.x); + break :cursor_style if (cell.raw.hasStyling()) + cell.style + else + .{}; + }; + // If we have preedit text, we don't setup a cursor if (preedit != null) break :cursor; - // Prepare the cursor cell contents. + // If there isn't a cursor visual style requested then + // we don't render a cursor. const style = cursor_style_ orelse break :cursor; + + // Determine the cursor color. const cursor_color = cursor_color: { // If an explicit cursor color was set by OSC 12, use that. if (state.colors.cursor) |v| break :cursor_color v; @@ -2801,24 +2817,30 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Use our configured color if specified if (self.config.cursor_color) |v| switch (v) { .color => |color| break :cursor_color color.toTerminalRGB(), + inline .@"cell-foreground", .@"cell-background", => |_, tag| { - const sty: terminal.Style = state.cursor.style; - const fg_style = sty.fg(.{ + const fg_style = cursor_style.fg(.{ .default = state.colors.foreground, .palette = &state.colors.palette, .bold = self.config.bold_color, }); - const bg_style = sty.bg( + const bg_style = cursor_style.bg( &state.cursor.cell, &state.colors.palette, ) orelse state.colors.background; break :cursor_color switch (tag) { .color => unreachable, - .@"cell-foreground" => if (sty.flags.inverse) bg_style else fg_style, - .@"cell-background" => if (sty.flags.inverse) fg_style else bg_style, + .@"cell-foreground" => if (cursor_style.flags.inverse) + bg_style + else + fg_style, + .@"cell-background" => if (cursor_style.flags.inverse) + fg_style + else + bg_style, }; }, }; @@ -2833,9 +2855,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { ); // If the cursor is visible then we set our uniforms. - if (style == .block) cursor_uniforms: { - const cursor_vp = state.cursor.viewport orelse - break :cursor_uniforms; + if (style == .block) { const wide = state.cursor.cell.wide; self.uniforms.cursor_pos = .{ @@ -2862,21 +2882,26 @@ pub fn Renderer(comptime GraphicsAPI: type) type { break :blk txt.color.toTerminalRGB(); } - const sty = state.cursor.style; - const fg_style = sty.fg(.{ + const fg_style = cursor_style.fg(.{ .default = state.colors.foreground, .palette = &state.colors.palette, .bold = self.config.bold_color, }); - const bg_style = sty.bg( + const bg_style = cursor_style.bg( &state.cursor.cell, &state.colors.palette, ) orelse state.colors.background; break :blk switch (txt) { // If the cell is reversed, use the opposite cell color instead. - .@"cell-foreground" => if (sty.flags.inverse) bg_style else fg_style, - .@"cell-background" => if (sty.flags.inverse) fg_style else bg_style, + .@"cell-foreground" => if (cursor_style.flags.inverse) + bg_style + else + fg_style, + .@"cell-background" => if (cursor_style.flags.inverse) + fg_style + else + bg_style, else => unreachable, }; } else state.colors.background; From 56b69ff0fd966188331a841c93a97522af7a3891 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 22 Nov 2025 21:06:31 -0800 Subject: [PATCH 098/209] datastruct: make CircBuf use the assumeCapacity pattern --- src/datastruct/circ_buf.zig | 27 +++++++++++++++++--------- src/terminal/search/sliding_window.zig | 4 ++-- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/datastruct/circ_buf.zig b/src/datastruct/circ_buf.zig index baef6f9cf..0caa9e85d 100644 --- a/src/datastruct/circ_buf.zig +++ b/src/datastruct/circ_buf.zig @@ -91,15 +91,24 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { self.full = self.head == self.tail; } - /// Append a slice to the buffer. If the buffer cannot fit the - /// entire slice then an error will be returned. It is up to the - /// caller to rotate the circular buffer if they want to overwrite - /// the oldest data. - pub fn appendSlice( + /// Append a single value to the buffer, assuming there is capacity. + pub fn appendAssumeCapacity(self: *Self, v: T) void { + assert(!self.full); + self.storage[self.head] = v; + self.head += 1; + if (self.head >= self.storage.len) self.head = 0; + self.full = self.head == self.tail; + } + + /// Append a slice to the buffer. + pub fn appendSliceAssumeCapacity( self: *Self, slice: []const T, - ) Allocator.Error!void { - const storage = self.getPtrSlice(self.len(), slice.len); + ) void { + const storage = self.getPtrSlice( + self.len(), + slice.len, + ); fastmem.copy(T, storage[0], slice[0..storage[0].len]); fastmem.copy(T, storage[1], slice[storage[0].len..]); } @@ -456,7 +465,7 @@ test "CircBuf append slice" { var buf = try Buf.init(alloc, 5); defer buf.deinit(alloc); - try buf.appendSlice("hello"); + buf.appendSliceAssumeCapacity("hello"); { var it = buf.iterator(.forward); try testing.expect(it.next().?.* == 'h'); @@ -486,7 +495,7 @@ test "CircBuf append slice with wrap" { try testing.expect(!buf.full); try testing.expectEqual(@as(usize, 2), buf.len()); - try buf.appendSlice("AB"); + buf.appendSliceAssumeCapacity("AB"); { var it = buf.iterator(.forward); try testing.expect(it.next().?.* == 0); diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index 2d09c781a..b0df3c13b 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -444,8 +444,8 @@ pub const SlidingWindow = struct { try self.meta.ensureUnusedCapacity(self.alloc, 1); // Append our new node to the circular buffer. - try self.data.appendSlice(written); - try self.meta.append(meta); + self.data.appendSliceAssumeCapacity(written); + self.meta.appendAssumeCapacity(meta); self.assertIntegrity(); return written.len; From ec5bdf1a5a7ac3172e5103d6eb92b109c78980d5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 22 Nov 2025 21:06:31 -0800 Subject: [PATCH 099/209] terminal: highlights --- src/lib_vt.zig | 1 + src/terminal/PageList.zig | 4 + src/terminal/highlight.zig | 154 +++++++++++++++++++++++++++++++++++++ src/terminal/main.zig | 1 + 4 files changed, 160 insertions(+) create mode 100644 src/terminal/highlight.zig diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 95b308aab..03a883e20 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -26,6 +26,7 @@ pub const point = terminal.point; pub const color = terminal.color; pub const device_status = terminal.device_status; pub const formatter = terminal.formatter; +pub const highlight = terminal.highlight; pub const kitty = terminal.kitty; pub const modes = terminal.modes; pub const page = terminal.page; diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 0e793a254..53c0c346b 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -3729,7 +3729,11 @@ pub const PageIterator = struct { pub const Chunk = struct { node: *List.Node, + + /// Start y index (inclusive) of this chunk in the page. start: size.CellCountInt, + + /// End y index (exclusive) of this chunk in the page. end: size.CellCountInt, pub fn rows(self: Chunk) []Row { diff --git a/src/terminal/highlight.zig b/src/terminal/highlight.zig new file mode 100644 index 000000000..626d6e471 --- /dev/null +++ b/src/terminal/highlight.zig @@ -0,0 +1,154 @@ +//! Highlights are any contiguous sequences of cells that should +//! be called out in some way, most commonly for text selection but +//! also search results or any other purpose. +//! +//! Within the terminal package, a highlight is a generic concept +//! that represents a range of cells. + +// NOTE: The plan is for highlights to ultimately replace Selection +// completely. Selection is deeply tied to various parts of the Ghostty +// internals so this may take some time. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = @import("../quirks.zig").inlineAssert; +const size = @import("size.zig"); +const PageList = @import("PageList.zig"); +const PageChunk = PageList.PageIterator.Chunk; +const Pin = PageList.Pin; +const Screen = @import("Screen.zig"); + +/// An untracked highlight is a highlight that stores its highlighted +/// area as a top-left and bottom-right screen pin. Since it is untracked, +/// the pins are only valid for the current terminal state and may not +/// be safe to use after any terminal modifications. +/// +/// For rectangle highlights/selections, the downstream consumer of this +/// code is expected to interpret the pins in whatever shape they want. +/// For example, a rectangular selection would interpret the pins as +/// setting the x bounds for each row between start.y and end.y. +/// +/// To simplify all operations, start MUST be before or equal to end. +pub const Untracked = struct { + start: Pin, + end: Pin, +}; + +/// A tracked highlight is a highlight that stores its highlighted +/// area as tracked pins within a screen. +/// +/// A tracked highlight ensures that the pins remain valid even as +/// the terminal state changes. Because of this, tracked highlights +/// have more operations available to them. +/// +/// There is more overhead to creating and maintaining tracked highlights. +/// If you're manipulating highlights that are untracked and you're sure +/// that the terminal state won't change, you can use the `initAssume` +/// function. +pub const Tracked = struct { + start: *Pin, + end: *Pin, + + pub fn init( + screen: *Screen, + start: Pin, + end: Pin, + ) Allocator.Error!Tracked { + const start_tracked = try screen.pages.trackPin(start); + errdefer screen.pages.untrackPin(start_tracked); + const end_tracked = try screen.pages.trackPin(end); + errdefer screen.pages.untrackPin(end_tracked); + return .{ + .start = start_tracked, + .end = end_tracked, + }; + } + + /// Initializes a tracked highlight by assuming that the provided + /// pins are already tracked. This allows callers to perform tracked + /// operations without the overhead of tracking the pins, if the + /// caller can guarantee that the pins are already tracked or that + /// the terminal state will not change. + /// + /// Do not call deinit on highlights created with this function. + pub fn initAssume( + start: *Pin, + end: *Pin, + ) Tracked { + return .{ + .start = start, + .end = end, + }; + } + + pub fn deinit( + self: Tracked, + screen: *Screen, + ) void { + screen.pages.untrackPin(self.start); + screen.pages.untrackPin(self.end); + } +}; + +/// A flattened highlight is a highlight that stores its highlighted +/// area as a list of page chunks. This representation allows for +/// traversing the entire highlighted area without needing to read any +/// terminal state or dereference any page nodes (which may have been +/// pruned). +pub const Flattened = struct { + /// The page chunks that make up this highlight. This handles the + /// y bounds since chunks[0].start is the first highlighted row + /// and chunks[len - 1].end is the last highlighted row (exclsive). + chunks: std.MultiArrayList(PageChunk), + + /// The x bounds of the highlight. `bot_x` may be less than `top_x` + /// for typical left-to-right highlights: can start the selection right + /// of the end on a higher row. + top_x: size.CellCountInt, + bot_x: size.CellCountInt, + + /// Exposed for easier type references. + pub const Chunk = PageChunk; + + pub const empty: Flattened = .{ + .chunks = .empty, + .top_x = 0, + .bot_x = 0, + }; + + pub fn init( + alloc: Allocator, + start: Pin, + end: Pin, + ) Allocator.Error!Flattened { + var result: std.MultiArrayList(PageChunk) = .empty; + errdefer result.deinit(alloc); + var it = start.pageIterator(.right_down, end); + while (it.next()) |chunk| try result.append(alloc, chunk); + return .{ + .chunks = result, + .top_x = start.x, + .end_x = end.x, + }; + } + + /// Convert to an Untracked highlight. + pub fn untracked(self: Flattened) Untracked { + const slice = self.chunks.slice(); + const nodes = slice.items(.node); + const starts = slice.items(.start); + const ends = slice.items(.end); + return .{ + .start = .{ + .node = nodes[0], + .x = self.top_x, + .y = starts[0], + }, + .end = .{ + .node = nodes[nodes.len - 1], + .x = self.bot_x, + .y = ends[ends.len - 1] - 1, + }, + }; + } +}; diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 77a96bfee..fc7584c1a 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -15,6 +15,7 @@ pub const point = @import("point.zig"); pub const color = @import("color.zig"); pub const device_status = @import("device_status.zig"); pub const formatter = @import("formatter.zig"); +pub const highlight = @import("highlight.zig"); pub const kitty = @import("kitty.zig"); pub const modes = @import("modes.zig"); pub const page = @import("page.zig"); From 05d6315e822f6574c6586348540d8626cbbd1cb7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 22 Nov 2025 21:06:31 -0800 Subject: [PATCH 100/209] terminal: add a SlidingWindow2 that uses highlights --- src/terminal/search.zig | 1 + src/terminal/search/sliding_window2.zig | 1400 +++++++++++++++++++++++ 2 files changed, 1401 insertions(+) create mode 100644 src/terminal/search/sliding_window2.zig diff --git a/src/terminal/search.zig b/src/terminal/search.zig index e69603c25..1ac18515c 100644 --- a/src/terminal/search.zig +++ b/src/terminal/search.zig @@ -19,4 +19,5 @@ test { // Non-public APIs _ = @import("search/sliding_window.zig"); + _ = @import("search/sliding_window2.zig"); } diff --git a/src/terminal/search/sliding_window2.zig b/src/terminal/search/sliding_window2.zig new file mode 100644 index 000000000..6aad0bff9 --- /dev/null +++ b/src/terminal/search/sliding_window2.zig @@ -0,0 +1,1400 @@ +const std = @import("std"); +const assert = @import("../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const CircBuf = @import("../../datastruct/main.zig").CircBuf; +const terminal = @import("../main.zig"); +const point = terminal.point; +const size = terminal.size; +const PageList = terminal.PageList; +const Pin = PageList.Pin; +const Selection = terminal.Selection; +const Screen = terminal.Screen; +const PageFormatter = @import("../formatter.zig").PageFormatter; +const FlattenedHighlight = terminal.highlight.Flattened; + +/// Searches page nodes via a sliding window. The sliding window maintains +/// the invariant that data isn't pruned until (1) we've searched it and +/// (2) we've accounted for overlaps across pages to fit the needle. +/// +/// The sliding window is first initialized empty. Pages are then appended +/// in the order to search them. The sliding window supports both a forward +/// and reverse order specified via `init`. The pages should be appended +/// in the correct order matching the search direction. +/// +/// All appends grow the window. The window is only pruned when a search +/// is done (positive or negative match) via `next()`. +/// +/// To avoid unnecessary memory growth, the recommended usage is to +/// call `next()` until it returns null and then `append` the next page +/// and repeat the process. This will always maintain the minimum +/// required memory to search for the needle. +/// +/// The caller is responsible for providing the pages and ensuring they're +/// in the proper order. The SlidingWindow itself doesn't own the pages, but +/// it will contain pointers to them in order to return selections. If any +/// pages become invalid, the caller should clear the sliding window and +/// start over. +pub const SlidingWindow = struct { + /// The allocator to use for all the data within this window. We + /// store this rather than passing it around because its already + /// part of multiple elements (eg. Meta's CellMap) and we want to + /// ensure we always use a consistent allocator. Additionally, only + /// a small amount of sliding windows are expected to be in use + /// at any one time so the memory overhead isn't that large. + alloc: Allocator, + + /// The data buffer is a circular buffer of u8 that contains the + /// encoded page text that we can use to search for the needle. + data: DataBuf, + + /// The meta buffer is a circular buffer that contains the metadata + /// about the pages we're searching. This usually isn't that large + /// so callers must iterate through it to find the offset to map + /// data to meta. + meta: MetaBuf, + + /// Buffer that can fit any amount of chunks necessary for next + /// to never fail allocation. + chunk_buf: std.MultiArrayList(FlattenedHighlight.Chunk), + + /// Offset into data for our current state. This handles the + /// situation where our search moved through meta[0] but didn't + /// do enough to prune it. + data_offset: usize = 0, + + /// The needle we're searching for. Does own the memory. + needle: []const u8, + + /// The search direction. If the direction is forward then pages should + /// be appended in forward linked list order from the PageList. If the + /// direction is reverse then pages should be appended in reverse order. + /// + /// This is important because in most cases, a reverse search is going + /// to be more desirable to search from the end of the active area + /// backwards so more recent data is found first. Supporting both is + /// trivial though and will let us do more complex optimizations in the + /// future (e.g. starting from the viewport and doing a forward/reverse + /// concurrently from that point). + direction: Direction, + + /// A buffer to store the overlap search data. This is used to search + /// overlaps between pages where the match starts on one page and + /// ends on another. The length is always `needle.len * 2`. + overlap_buf: []u8, + + const Direction = enum { forward, reverse }; + const DataBuf = CircBuf(u8, 0); + const MetaBuf = CircBuf(Meta, undefined); + const Meta = struct { + node: *PageList.List.Node, + cell_map: std.ArrayList(point.Coordinate), + + pub fn deinit(self: *Meta, alloc: Allocator) void { + self.cell_map.deinit(alloc); + } + }; + + pub fn init( + alloc: Allocator, + direction: Direction, + needle_unowned: []const u8, + ) Allocator.Error!SlidingWindow { + var data = try DataBuf.init(alloc, 0); + errdefer data.deinit(alloc); + + var meta = try MetaBuf.init(alloc, 0); + errdefer meta.deinit(alloc); + + const needle = try alloc.dupe(u8, needle_unowned); + errdefer alloc.free(needle); + switch (direction) { + .forward => {}, + .reverse => std.mem.reverse(u8, needle), + } + + const overlap_buf = try alloc.alloc(u8, needle.len * 2); + errdefer alloc.free(overlap_buf); + + return .{ + .alloc = alloc, + .data = data, + .meta = meta, + .chunk_buf = .empty, + .needle = needle, + .direction = direction, + .overlap_buf = overlap_buf, + }; + } + + pub fn deinit(self: *SlidingWindow) void { + self.alloc.free(self.overlap_buf); + self.alloc.free(self.needle); + self.chunk_buf.deinit(self.alloc); + self.data.deinit(self.alloc); + + var meta_it = self.meta.iterator(.forward); + while (meta_it.next()) |meta| meta.deinit(self.alloc); + self.meta.deinit(self.alloc); + } + + /// Clear all data but retain allocated capacity. + pub fn clearAndRetainCapacity(self: *SlidingWindow) void { + var meta_it = self.meta.iterator(.forward); + while (meta_it.next()) |meta| meta.deinit(self.alloc); + self.meta.clear(); + self.data.clear(); + self.data_offset = 0; + } + + /// Search the window for the next occurrence of the needle. As + /// the window moves, the window will prune itself while maintaining + /// the invariant that the window is always big enough to contain + /// the needle. + /// + /// This returns a flattened highlight on a match. The + /// flattened highlight requires allocation and is therefore more expensive + /// than a normal selection, but it is more efficient to render since it + /// has all the information without having to dereference pointers into + /// the terminal state. + /// + /// The flattened highlight chunks reference internal memory for this + /// sliding window and are only valid until the next call to `next()` + /// or `append()`. If the caller wants to retain the flattened highlight + /// then they should clone it. + pub fn next(self: *SlidingWindow) ?FlattenedHighlight { + const slices = slices: { + // If we have less data then the needle then we can't possibly match + const data_len = self.data.len(); + if (data_len < self.needle.len) return null; + + break :slices self.data.getPtrSlice( + self.data_offset, + data_len - self.data_offset, + ); + }; + + // Search the first slice for the needle. + if (std.mem.indexOf(u8, slices[0], self.needle)) |idx| { + return self.highlight( + idx, + self.needle.len, + ); + } + + // Search the overlap buffer for the needle. + if (slices[0].len > 0 and slices[1].len > 0) overlap: { + // Get up to needle.len - 1 bytes from each side (as much as + // we can) and store it in the overlap buffer. + const prefix: []const u8 = prefix: { + const len = @min(slices[0].len, self.needle.len - 1); + const idx = slices[0].len - len; + break :prefix slices[0][idx..]; + }; + const suffix: []const u8 = suffix: { + const len = @min(slices[1].len, self.needle.len - 1); + break :suffix slices[1][0..len]; + }; + const overlap_len = prefix.len + suffix.len; + assert(overlap_len <= self.overlap_buf.len); + @memcpy(self.overlap_buf[0..prefix.len], prefix); + @memcpy(self.overlap_buf[prefix.len..overlap_len], suffix); + + // Search the overlap + const idx = std.mem.indexOf( + u8, + self.overlap_buf[0..overlap_len], + self.needle, + ) orelse break :overlap; + + // We found a match in the overlap buffer. We need to map the + // index back to the data buffer in order to get our selection. + return self.highlight( + slices[0].len - prefix.len + idx, + self.needle.len, + ); + } + + // Search the last slice for the needle. + if (std.mem.indexOf(u8, slices[1], self.needle)) |idx| { + return self.highlight( + slices[0].len + idx, + self.needle.len, + ); + } + + // No match. We keep `needle.len - 1` bytes available to + // handle the future overlap case. + var meta_it = self.meta.iterator(.reverse); + prune: { + var saved: usize = 0; + while (meta_it.next()) |meta| { + const needed = self.needle.len - 1 - saved; + if (meta.cell_map.items.len >= needed) { + // We save up to this meta. We set our data offset + // to exactly where it needs to be to continue + // searching. + self.data_offset = meta.cell_map.items.len - needed; + break; + } + + saved += meta.cell_map.items.len; + } else { + // If we exited the while loop naturally then we + // never got the amount we needed and so there is + // nothing to prune. + assert(saved < self.needle.len - 1); + break :prune; + } + + const prune_count = self.meta.len() - meta_it.idx; + if (prune_count == 0) { + // This can happen if we need to save up to the first + // meta value to retain our window. + break :prune; + } + + // We can now delete all the metas up to but NOT including + // the meta we found through meta_it. + meta_it = self.meta.iterator(.forward); + var prune_data_len: usize = 0; + for (0..prune_count) |_| { + const meta = meta_it.next().?; + prune_data_len += meta.cell_map.items.len; + meta.deinit(self.alloc); + } + self.meta.deleteOldest(prune_count); + self.data.deleteOldest(prune_data_len); + } + + // Our data offset now moves to needle.len - 1 from the end so + // that we can handle the overlap case. + self.data_offset = self.data.len() - self.needle.len + 1; + + self.assertIntegrity(); + return null; + } + + /// Return a flattened highlight for the given start and length. + /// + /// The flattened highlight can be used to render the highlight + /// in the most efficent way because it doesn't require a terminal + /// lock to access terminal data to compare whether some viewport + /// matches the highlight (because it doesn't need to traverse + /// the page nodes). + /// + /// The start index is assumed to be relative to the offset. i.e. + /// index zero is actually at `self.data[self.data_offset]`. The + /// selection will account for the offset. + fn highlight( + self: *SlidingWindow, + start_offset: usize, + len: usize, + ) terminal.highlight.Flattened { + const start = start_offset + self.data_offset; + const end = start + len - 1; + if (comptime std.debug.runtime_safety) { + assert(start < self.data.len()); + assert(start + len <= self.data.len()); + } + + // Clear our previous chunk buffer to store this result + self.chunk_buf.clearRetainingCapacity(); + var result: terminal.highlight.Flattened = .empty; + + // Go through the meta nodes to find our start. + const tl: struct { + /// If non-null, we need to continue searching for the bottom-right. + br: ?struct { + it: MetaBuf.Iterator, + consumed: usize, + }, + + /// Data to prune, both are lengths. + prune: struct { + meta: usize, + data: usize, + }, + } = tl: { + var meta_it = self.meta.iterator(.forward); + var meta_consumed: usize = 0; + while (meta_it.next()) |meta| { + // Always increment our consumed count so that our index + // is right for the end search if we do it. + const prior_meta_consumed = meta_consumed; + meta_consumed += meta.cell_map.items.len; + + // meta_i is the index we expect to find the match in the + // cell map within this meta if it contains it. + const meta_i = start - prior_meta_consumed; + + // This meta doesn't contain the match. This means we + // can also prune this set of data because we only look + // forward. + if (meta_i >= meta.cell_map.items.len) continue; + + // Now we look for the end. In MOST cases it is the same as + // our starting chunk because highlights are usually small and + // not on a boundary, so let's optimize for that. + const end_i = end - prior_meta_consumed; + if (end_i < meta.cell_map.items.len) { + @branchHint(.likely); + + // The entire highlight is within this meta. + const start_map = meta.cell_map.items[meta_i]; + const end_map = meta.cell_map.items[end_i]; + result.top_x = start_map.x; + result.bot_x = end_map.x; + self.chunk_buf.appendAssumeCapacity(.{ + .node = meta.node, + .start = @intCast(start_map.y), + .end = @intCast(end_map.y + 1), + }); + + break :tl .{ + .br = null, + .prune = .{ + .meta = meta_it.idx - 1, + .data = prior_meta_consumed, + }, + }; + } else { + // We found the meta that contains the start of the match + // only. Consume this entire node from our start offset. + const map = meta.cell_map.items[meta_i]; + result.top_x = map.x; + self.chunk_buf.appendAssumeCapacity(.{ + .node = meta.node, + .start = @intCast(map.y), + .end = meta.node.data.size.rows, + }); + + break :tl .{ + .br = .{ + .it = meta_it, + .consumed = meta_consumed, + }, + .prune = .{ + .meta = meta_it.idx - 1, + .data = prior_meta_consumed, + }, + }; + } + } else { + // Precondition that the start index is within the data buffer. + unreachable; + } + }; + + // Search for our end. + if (tl.br) |br| { + var meta_it = br.it; + var meta_consumed: usize = br.consumed; + while (meta_it.next()) |meta| { + // meta_i is the index we expect to find the match in the + // cell map within this meta if it contains it. + const meta_i = end - meta_consumed; + if (meta_i >= meta.cell_map.items.len) { + // This meta doesn't contain the match. We still add it + // to our results because we want the full flattened list. + self.chunk_buf.appendAssumeCapacity(.{ + .node = meta.node, + .start = 0, + .end = meta.node.data.size.rows, + }); + + meta_consumed += meta.cell_map.items.len; + continue; + } + + // We found it + const map = meta.cell_map.items[meta_i]; + result.bot_x = map.x; + self.chunk_buf.appendAssumeCapacity(.{ + .node = meta.node, + .start = 0, + .end = @intCast(map.y + 1), + }); + break; + } else { + // Precondition that the end index is within the data buffer. + unreachable; + } + } + + // Our offset into the current meta block is the start index + // minus the amount of data fully consumed. We then add one + // to move one past the match so we don't repeat it. + self.data_offset = start - tl.prune.data + 1; + + // If we went beyond our initial meta node we can prune. + if (tl.prune.meta > 0) { + // Deinit all our memory in the meta blocks prior to our + // match. + var meta_it = self.meta.iterator(.forward); + var meta_consumed: usize = 0; + for (0..tl.prune.meta) |_| { + const meta: *Meta = meta_it.next().?; + meta_consumed += meta.cell_map.items.len; + meta.deinit(self.alloc); + } + if (comptime std.debug.runtime_safety) { + assert(meta_it.idx == tl.prune.meta); + assert(meta_it.next().?.node == self.chunk_buf.items(.node)[0]); + } + self.meta.deleteOldest(tl.prune.meta); + + // Delete all the data up to our current index. + assert(tl.prune.data > 0); + self.data.deleteOldest(tl.prune.data); + } + + switch (self.direction) { + .forward => {}, + .reverse => { + if (self.chunk_buf.len > 1) { + // Reverse all our chunks. This should be pretty obvious why. + const slice = self.chunk_buf.slice(); + const nodes = slice.items(.node); + const starts = slice.items(.start); + const ends = slice.items(.end); + std.mem.reverse(*PageList.List.Node, nodes); + std.mem.reverse(size.CellCountInt, starts); + std.mem.reverse(size.CellCountInt, ends); + + // Now normally with forward traversal with multiple pages, + // the suffix of the first page and the prefix of the last + // page are used. + // + // For a reverse traversal, this is inverted (since the + // pages are in reverse order we get the suffix of the last + // page and the prefix of the first page). So we need to + // invert this. + // + // We DON'T need to do this for any middle pages because + // they always use the full page. + // + // We DON'T need to do this for chunks.len == 1 because + // the pages themselves aren't reversed and we don't have + // any prefix/suffix problems. + // + // This is a fixup that makes our start/end match the + // same logic as the loops above if they were in forward + // order. + assert(nodes.len >= 2); + starts[0] = ends[0] - 1; + ends[0] = nodes[0].data.size.rows; + ends[nodes.len - 1] = starts[nodes.len - 1] + 1; + starts[nodes.len - 1] = 0; + } + + // X values also need to be reversed since the top/bottom + // are swapped for the nodes. + const top_x = result.top_x; + result.top_x = result.bot_x; + result.bot_x = top_x; + }, + } + + // Copy over our MultiArrayList so it points to the proper memory. + result.chunks = self.chunk_buf; + return result; + } + + /// Add a new node to the sliding window. This will always grow + /// the sliding window; data isn't pruned until it is consumed + /// via a search (via next()). + /// + /// Returns the number of bytes of content added to the sliding window. + /// The total bytes will be larger since this omits metadata, but it is + /// an accurate measure of the text content size added. + pub fn append( + self: *SlidingWindow, + node: *PageList.List.Node, + ) Allocator.Error!usize { + // Initialize our metadata for the node. + var meta: Meta = .{ + .node = node, + .cell_map = .empty, + }; + errdefer meta.deinit(self.alloc); + + // This is suboptimal but we need to encode the page once to + // temporary memory, and then copy it into our circular buffer. + // In the future, we should benchmark and see if we can encode + // directly into the circular buffer. + var encoded: std.Io.Writer.Allocating = .init(self.alloc); + defer encoded.deinit(); + + // Encode the page into the buffer. + const formatter: PageFormatter = formatter: { + var formatter: PageFormatter = .init(&meta.node.data, .plain); + formatter.point_map = .{ + .alloc = self.alloc, + .map = &meta.cell_map, + }; + break :formatter formatter; + }; + formatter.format(&encoded.writer) catch { + // writer uses anyerror but the only realistic error on + // an ArrayList is out of memory. + return error.OutOfMemory; + }; + assert(meta.cell_map.items.len == encoded.written().len); + + // If the node we're adding isn't soft-wrapped, we add the + // trailing newline. + const row = node.data.getRow(node.data.size.rows - 1); + if (!row.wrap) { + encoded.writer.writeByte('\n') catch return error.OutOfMemory; + try meta.cell_map.append( + self.alloc, + meta.cell_map.getLastOrNull() orelse .{ + .x = 0, + .y = 0, + }, + ); + } + + // Get our written data. If we're doing a reverse search then we + // need to reverse all our encodings. + const written = encoded.written(); + switch (self.direction) { + .forward => {}, + .reverse => { + std.mem.reverse(u8, written); + std.mem.reverse(point.Coordinate, meta.cell_map.items); + }, + } + + // Ensure our buffers are big enough to store what we need. + try self.data.ensureUnusedCapacity(self.alloc, written.len); + try self.meta.ensureUnusedCapacity(self.alloc, 1); + try self.chunk_buf.ensureTotalCapacity(self.alloc, self.meta.capacity()); + + // Append our new node to the circular buffer. + self.data.appendSliceAssumeCapacity(written); + self.meta.appendAssumeCapacity(meta); + + self.assertIntegrity(); + return written.len; + } + + /// Only for tests! + fn testChangeNeedle(self: *SlidingWindow, new: []const u8) void { + assert(new.len == self.needle.len); + self.alloc.free(self.needle); + self.needle = self.alloc.dupe(u8, new) catch unreachable; + } + + fn assertIntegrity(self: *const SlidingWindow) void { + if (comptime !std.debug.runtime_safety) return; + + // We don't run integrity checks on Valgrind because its soooooo slow, + // Valgrind is our integrity checker, and we run these during unit + // tests (non-Valgrind) anyways so we're verifying anyways. + if (std.valgrind.runningOnValgrind() > 0) return; + + // Integrity check: verify our data matches our metadata exactly. + var meta_it = self.meta.iterator(.forward); + var data_len: usize = 0; + while (meta_it.next()) |m| data_len += m.cell_map.items.len; + assert(data_len == self.data.len()); + + // Integrity check: verify our data offset is within bounds. + assert(self.data_offset < self.data.len()); + } +}; + +test "SlidingWindow empty on init" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "boo!"); + defer w.deinit(); + try testing.expectEqual(0, w.data.len()); + try testing.expectEqual(0, w.meta.len()); +} + +test "SlidingWindow single append" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "boo!"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello. boo! hello. boo!"); + + // We want to test single-page cases. + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + + // We should be able to find two matches. + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 10, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end)); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 22, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end)); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + +test "SlidingWindow single append no match" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "nope!"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello. boo! hello. boo!"); + + // We want to test single-page cases. + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + + // No matches + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // Should still keep the page + try testing.expectEqual(1, w.meta.len()); +} + +test "SlidingWindow two pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "boo!"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\n"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + try s.testWriteString("hello. boo!"); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node.next.?); + + // Search should find two matches + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 76, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 79, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 10, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + +test "SlidingWindow two pages match across boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "hello, world"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("hell"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("o, world!"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node.next.?); + + // Search should find a match + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 76, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // We shouldn't prune because we don't have enough space + try testing.expectEqual(2, w.meta.len()); +} + +test "SlidingWindow two pages no match across boundary with newline" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "hello, world"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("hell"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\no, world!"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node.next.?); + + // Search should NOT find a match + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // We shouldn't prune because we don't have enough space + try testing.expectEqual(2, w.meta.len()); +} + +test "SlidingWindow two pages no match across boundary with newline reverse" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "hello, world"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("hell"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\no, world!"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + + // Add both pages in reverse order + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node.next.?); + _ = try w.append(node); + + // Search should NOT find a match + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + +test "SlidingWindow two pages no match prunes first page" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "nope!"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\n"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + try s.testWriteString("hello. boo!"); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node.next.?); + + // Search should find nothing + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // We should've pruned our page because the second page + // has enough text to contain our needle. + try testing.expectEqual(1, w.meta.len()); +} + +test "SlidingWindow two pages no match keeps both pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\n"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + try s.testWriteString("hello. boo!"); + + // Imaginary needle for search. Doesn't match! + var needle_list: std.ArrayList(u8) = .empty; + defer needle_list.deinit(alloc); + try needle_list.appendNTimes(alloc, 'x', first_page_rows * s.pages.cols); + const needle: []const u8 = needle_list.items; + + var w: SlidingWindow = try .init(alloc, .forward, needle); + defer w.deinit(); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node.next.?); + + // Search should find nothing + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // No pruning because both pages are needed to fit needle. + try testing.expectEqual(2, w.meta.len()); +} + +test "SlidingWindow single append across circular buffer boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "abc"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("XXXXXXXXXXXXXXXXXXXboo!XXXXX"); + + // We are trying to break a circular buffer boundary so the way we + // do this is to duplicate the data then do a failing search. This + // will cause the first page to be pruned. The next time we append we'll + // put it in the middle of the circ buffer. We assert this so that if + // our implementation changes our test will fail. + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node); + { + // No wrap around yet + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len == 0); + } + + // Search non-match, prunes page + try testing.expect(w.next() == null); + try testing.expectEqual(1, w.meta.len()); + + // Change the needle, just needs to be the same length (not a real API) + w.testChangeNeedle("boo"); + + // Add new page, now wraps + _ = try w.append(node); + { + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len > 0); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 21, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); +} + +test "SlidingWindow single append match on boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "abcd"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("o!XXXXXXXXXXXXXXXXXXXbo"); + + // We need to surgically modify the last row to be soft-wrapped + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + node.data.getRow(node.data.size.rows - 1).wrap = true; + + // We are trying to break a circular buffer boundary so the way we + // do this is to duplicate the data then do a failing search. This + // will cause the first page to be pruned. The next time we append we'll + // put it in the middle of the circ buffer. We assert this so that if + // our implementation changes our test will fail. + _ = try w.append(node); + _ = try w.append(node); + { + // No wrap around yet + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len == 0); + } + + // Search non-match, prunes page + try testing.expect(w.next() == null); + try testing.expectEqual(1, w.meta.len()); + + // Change the needle, just needs to be the same length (not a real API) + w.testChangeNeedle("boo!"); + + // Add new page, now wraps + _ = try w.append(node); + { + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len > 0); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 21, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); +} + +test "SlidingWindow single append reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "boo!"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello. boo! hello. boo!"); + + // We want to test single-page cases. + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + + // We should be able to find two matches. + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 22, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 10, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + +test "SlidingWindow single append no match reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "nope!"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello. boo! hello. boo!"); + + // We want to test single-page cases. + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + + // No matches + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // Should still keep the page + try testing.expectEqual(1, w.meta.len()); +} + +test "SlidingWindow two pages reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "boo!"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\n"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + try s.testWriteString("hello. boo!"); + + // Add both pages in reverse order + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node.next.?); + _ = try w.append(node); + + // Search should find two matches (in reverse order) + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 10, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 76, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 79, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + +test "SlidingWindow two pages match across boundary reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "hello, world"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "hell" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("hell"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("o, world!"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + + // Add both pages in reverse order + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node.next.?); + _ = try w.append(node); + + // Search should find a match + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 76, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // In reverse mode, the last appended meta (first original page) is large + // enough to contain needle.len - 1 bytes, so pruning occurs + try testing.expectEqual(1, w.meta.len()); +} + +test "SlidingWindow two pages no match prunes first page reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "nope!"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\n"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + try s.testWriteString("hello. boo!"); + + // Add both pages in reverse order + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node.next.?); + _ = try w.append(node); + + // Search should find nothing + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // We should've pruned our page because the second page + // has enough text to contain our needle. + try testing.expectEqual(1, w.meta.len()); +} + +test "SlidingWindow two pages no match keeps both pages reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\n"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + try s.testWriteString("hello. boo!"); + + // Imaginary needle for search. Doesn't match! + var needle_list: std.ArrayList(u8) = .empty; + defer needle_list.deinit(alloc); + try needle_list.appendNTimes(alloc, 'x', first_page_rows * s.pages.cols); + const needle: []const u8 = needle_list.items; + + var w: SlidingWindow = try .init(alloc, .reverse, needle); + defer w.deinit(); + + // Add both pages in reverse order + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node.next.?); + _ = try w.append(node); + + // Search should find nothing + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // No pruning because both pages are needed to fit needle. + try testing.expectEqual(2, w.meta.len()); +} + +test "SlidingWindow single append across circular buffer boundary reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "abc"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("XXXXXXXXXXXXXXXXXXXboo!XXXXX"); + + // We are trying to break a circular buffer boundary so the way we + // do this is to duplicate the data then do a failing search. This + // will cause the first page to be pruned. The next time we append we'll + // put it in the middle of the circ buffer. We assert this so that if + // our implementation changes our test will fail. + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node); + { + // No wrap around yet + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len == 0); + } + + // Search non-match, prunes page + try testing.expect(w.next() == null); + try testing.expectEqual(1, w.meta.len()); + + // Change the needle, just needs to be the same length (not a real API) + // testChangeNeedle doesn't reverse, so pass reversed needle for reverse mode + w.testChangeNeedle("oob"); + + // Add new page, now wraps + _ = try w.append(node); + { + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len > 0); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 21, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); +} + +test "SlidingWindow single append match on boundary reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "abcd"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("o!XXXXXXXXXXXXXXXXXXXbo"); + + // We need to surgically modify the last row to be soft-wrapped + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + node.data.getRow(node.data.size.rows - 1).wrap = true; + + // We are trying to break a circular buffer boundary so the way we + // do this is to duplicate the data then do a failing search. This + // will cause the first page to be pruned. The next time we append we'll + // put it in the middle of the circ buffer. We assert this so that if + // our implementation changes our test will fail. + _ = try w.append(node); + _ = try w.append(node); + { + // No wrap around yet + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len == 0); + } + + // Search non-match, prunes page + try testing.expect(w.next() == null); + try testing.expectEqual(1, w.meta.len()); + + // Change the needle, just needs to be the same length (not a real API) + // testChangeNeedle doesn't reverse, so pass reversed needle for reverse mode + w.testChangeNeedle("!oob"); + + // Add new page, now wraps + _ = try w.append(node); + { + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len > 0); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 21, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); +} From 6623c20c2dafd4320048e49b6d5a2ee802be24f9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Nov 2025 10:19:10 -0800 Subject: [PATCH 101/209] terminal: switch search to use flattened highlights --- src/terminal/highlight.zig | 12 + src/terminal/search.zig | 1 - src/terminal/search/Thread.zig | 41 +- src/terminal/search/active.zig | 24 +- src/terminal/search/pagelist.zig | 24 +- src/terminal/search/screen.zig | 91 +- src/terminal/search/sliding_window.zig | 410 ++++--- src/terminal/search/sliding_window2.zig | 1400 ----------------------- src/terminal/search/viewport.zig | 31 +- 9 files changed, 412 insertions(+), 1622 deletions(-) delete mode 100644 src/terminal/search/sliding_window2.zig diff --git a/src/terminal/highlight.zig b/src/terminal/highlight.zig index 626d6e471..772d4d54b 100644 --- a/src/terminal/highlight.zig +++ b/src/terminal/highlight.zig @@ -132,6 +132,18 @@ pub const Flattened = struct { }; } + pub fn deinit(self: *Flattened, alloc: Allocator) void { + self.chunks.deinit(alloc); + } + + pub fn clone(self: *const Flattened, alloc: Allocator) Allocator.Error!Flattened { + return .{ + .chunks = try self.chunks.clone(alloc), + .top_x = self.top_x, + .bot_x = self.bot_x, + }; + } + /// Convert to an Untracked highlight. pub fn untracked(self: Flattened) Untracked { const slice = self.chunks.slice(); diff --git a/src/terminal/search.zig b/src/terminal/search.zig index 1ac18515c..e69603c25 100644 --- a/src/terminal/search.zig +++ b/src/terminal/search.zig @@ -19,5 +19,4 @@ test { // Non-public APIs _ = @import("search/sliding_window.zig"); - _ = @import("search/sliding_window2.zig"); } diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 776dfc84a..fdd5f81bc 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -12,11 +12,13 @@ const std = @import("std"); const builtin = @import("builtin"); const testing = std.testing; const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; const Mutex = std.Thread.Mutex; const xev = @import("../../global.zig").xev; const internal_os = @import("../../os/main.zig"); const BlockingQueue = @import("../../datastruct/main.zig").BlockingQueue; const point = @import("../point.zig"); +const FlattenedHighlight = @import("../highlight.zig").Flattened; const PageList = @import("../PageList.zig"); const Screen = @import("../Screen.zig"); const ScreenSet = @import("../ScreenSet.zig"); @@ -387,7 +389,7 @@ pub const Event = union(enum) { /// Matches in the viewport have changed. The memory is owned by the /// search thread and is only valid during the callback. - viewport_matches: []const Selection, + viewport_matches: []const FlattenedHighlight, }; /// Search state. @@ -603,10 +605,13 @@ const Search = struct { // process will make it stale again. self.stale_viewport_matches = false; - var results: std.ArrayList(Selection) = .empty; - defer results.deinit(alloc); - while (self.viewport.next()) |sel| { - results.append(alloc, sel) catch |err| switch (err) { + var arena: ArenaAllocator = .init(alloc); + defer arena.deinit(); + const arena_alloc = arena.allocator(); + var results: std.ArrayList(FlattenedHighlight) = .empty; + while (self.viewport.next()) |hl| { + const hl_cloned = hl.clone(arena_alloc) catch continue; + results.append(arena_alloc, hl_cloned) catch |err| switch (err) { error.OutOfMemory => { log.warn( "error collecting viewport matches err={}", @@ -637,7 +642,12 @@ test { const Self = @This(); reset: std.Thread.ResetEvent = .{}, total: usize = 0, - viewport: []const Selection = &.{}, + viewport: []FlattenedHighlight = &.{}, + + fn deinit(self: *Self) void { + for (self.viewport) |*hl| hl.deinit(testing.allocator); + testing.allocator.free(self.viewport); + } fn callback(event: Event, userdata: ?*anyopaque) void { const ud: *Self = @ptrCast(@alignCast(userdata.?)); @@ -645,11 +655,16 @@ test { .complete => ud.reset.set(), .total_matches => |v| ud.total = v, .viewport_matches => |v| { + for (ud.viewport) |*hl| hl.deinit(testing.allocator); testing.allocator.free(ud.viewport); - ud.viewport = testing.allocator.dupe( - Selection, - v, + + ud.viewport = testing.allocator.alloc( + FlattenedHighlight, + v.len, ) catch unreachable; + for (ud.viewport, v) |*dst, src| { + dst.* = src.clone(testing.allocator) catch unreachable; + } }, } } @@ -665,7 +680,7 @@ test { try stream.nextSlice("Hello, world"); var ud: UserData = .{}; - defer alloc.free(ud.viewport); + defer ud.deinit(); var thread: Thread = try .init(alloc, .{ .mutex = &mutex, .terminal = &t, @@ -698,14 +713,14 @@ test { try testing.expectEqual(1, ud.total); try testing.expectEqual(1, ud.viewport.len); { - const sel = ud.viewport[0]; + const sel = ud.viewport[0].untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 7, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 11, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } diff --git a/src/terminal/search/active.zig b/src/terminal/search/active.zig index 2ace939e7..2329c40b0 100644 --- a/src/terminal/search/active.zig +++ b/src/terminal/search/active.zig @@ -3,6 +3,7 @@ const testing = std.testing; const Allocator = std.mem.Allocator; const point = @import("../point.zig"); const size = @import("../size.zig"); +const FlattenedHighlight = @import("../highlight.zig").Flattened; const PageList = @import("../PageList.zig"); const Selection = @import("../Selection.zig"); const SlidingWindow = @import("sliding_window.zig").SlidingWindow; @@ -96,7 +97,7 @@ pub const ActiveSearch = struct { /// Find the next match for the needle in the active area. This returns /// null when there are no more matches. - pub fn next(self: *ActiveSearch) ?Selection { + pub fn next(self: *ActiveSearch) ?FlattenedHighlight { return self.window.next(); } }; @@ -115,26 +116,28 @@ test "simple search" { _ = try search.update(&t.screens.active.pages); { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 0, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 2, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 2, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } try testing.expect(search.next() == null); } @@ -158,15 +161,16 @@ test "clear screen and search" { _ = try search.update(&t.screens.active.pages); { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 1, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 1, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } try testing.expect(search.next() == null); } diff --git a/src/terminal/search/pagelist.zig b/src/terminal/search/pagelist.zig index 8a01a61fb..bd1ce9ef7 100644 --- a/src/terminal/search/pagelist.zig +++ b/src/terminal/search/pagelist.zig @@ -5,6 +5,7 @@ const testing = std.testing; const CircBuf = @import("../../datastruct/main.zig").CircBuf; const terminal = @import("../main.zig"); const point = terminal.point; +const FlattenedHighlight = @import("../highlight.zig").Flattened; const Page = terminal.Page; const PageList = terminal.PageList; const Pin = PageList.Pin; @@ -97,7 +98,7 @@ pub const PageListSearch = struct { /// /// This does NOT access the PageList, so it can be called without /// a lock held. - pub fn next(self: *PageListSearch) ?Selection { + pub fn next(self: *PageListSearch) ?FlattenedHighlight { return self.window.next(); } @@ -149,26 +150,28 @@ test "simple search" { defer search.deinit(); { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 2, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 2, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 0, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } try testing.expect(search.next() == null); @@ -335,12 +338,13 @@ test "feed with match spanning page boundary" { try testing.expect(try search.feed()); // Should find the spanning match - const sel = search.next().?; - try testing.expect(sel.start().node != sel.end().node); + const h = search.next().?; + const sel = h.untracked(); + try testing.expect(sel.start.node != sel.end.node); { const str = try t.screens.active.selectionString( alloc, - .{ .sel = sel }, + .{ .sel = .init(sel.start, sel.end, false) }, ); defer alloc.free(str); try testing.expectEqualStrings(str, "Test"); diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index d2d138442..071ccd090 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -3,6 +3,7 @@ const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; const point = @import("../point.zig"); +const FlattenedHighlight = @import("../highlight.zig").Flattened; const PageList = @import("../PageList.zig"); const Pin = PageList.Pin; const Screen = @import("../Screen.zig"); @@ -44,8 +45,8 @@ pub const ScreenSearch = struct { /// is mostly immutable once found, while active area results may /// change. This lets us easily reset the active area results for a /// re-search scenario. - history_results: std.ArrayList(Selection), - active_results: std.ArrayList(Selection), + history_results: std.ArrayList(FlattenedHighlight), + active_results: std.ArrayList(FlattenedHighlight), /// History search state. const HistorySearch = struct { @@ -120,7 +121,9 @@ pub const ScreenSearch = struct { const alloc = self.allocator(); self.active.deinit(); if (self.history) |*h| h.deinit(self.screen); + for (self.active_results.items) |*hl| hl.deinit(alloc); self.active_results.deinit(alloc); + for (self.history_results.items) |*hl| hl.deinit(alloc); self.history_results.deinit(alloc); } @@ -145,11 +148,11 @@ pub const ScreenSearch = struct { pub fn matches( self: *ScreenSearch, alloc: Allocator, - ) Allocator.Error![]Selection { + ) Allocator.Error![]FlattenedHighlight { const active_results = self.active_results.items; const history_results = self.history_results.items; const results = try alloc.alloc( - Selection, + FlattenedHighlight, active_results.len + history_results.len, ); errdefer alloc.free(results); @@ -162,7 +165,7 @@ pub const ScreenSearch = struct { results[0..active_results.len], active_results, ); - std.mem.reverse(Selection, results[0..active_results.len]); + std.mem.reverse(FlattenedHighlight, results[0..active_results.len]); // History does a backward search, so we can just append them // after. @@ -247,13 +250,15 @@ pub const ScreenSearch = struct { // For the active area, we consume the entire search in one go // because the active area is generally small. const alloc = self.allocator(); - while (self.active.next()) |sel| { + while (self.active.next()) |hl| { // If this fails, then we miss a result since `active.next()` // moves forward and prunes data. In the future, we may want // to have some more robust error handling but the only // scenario this would fail is OOM and we're probably in // deeper trouble at that point anyways. - try self.active_results.append(alloc, sel); + var hl_cloned = try hl.clone(alloc); + errdefer hl_cloned.deinit(alloc); + try self.active_results.append(alloc, hl_cloned); } // We've consumed the entire active area, move to history. @@ -270,13 +275,15 @@ pub const ScreenSearch = struct { // Try to consume all the loaded matches in one go, because // the search is generally fast for loaded data. const alloc = self.allocator(); - while (history.searcher.next()) |sel| { + while (history.searcher.next()) |hl| { // Ignore selections that are found within the starting // node since those are covered by the active area search. - if (sel.start().node == history.start_pin.node) continue; + if (hl.chunks.items(.node)[0] == history.start_pin.node) continue; // Same note as tickActive for error handling. - try self.history_results.append(alloc, sel); + var hl_cloned = try hl.clone(alloc); + errdefer hl_cloned.deinit(alloc); + try self.history_results.append(alloc, hl_cloned); } // We need to be fed more data. @@ -291,6 +298,7 @@ pub const ScreenSearch = struct { /// /// The caller must hold the necessary locks to access the screen state. pub fn reloadActive(self: *ScreenSearch) Allocator.Error!void { + const alloc = self.allocator(); const list: *PageList = &self.screen.pages; if (try self.active.update(list)) |history_node| history: { // We need to account for any active area growth that would @@ -305,6 +313,7 @@ pub const ScreenSearch = struct { if (h.start_pin.garbage) { h.deinit(self.screen); self.history = null; + for (self.history_results.items) |*hl| hl.deinit(alloc); self.history_results.clearRetainingCapacity(); break :state null; } @@ -317,7 +326,7 @@ pub const ScreenSearch = struct { // initialize. var search: PageListSearch = try .init( - self.allocator(), + alloc, self.needle(), list, history_node, @@ -346,7 +355,6 @@ pub const ScreenSearch = struct { // collect all the results into a new list. We ASSUME that // reloadActive is being called frequently enough that there isn't // a massive amount of history to search here. - const alloc = self.allocator(); var window: SlidingWindow = try .init( alloc, .forward, @@ -361,17 +369,17 @@ pub const ScreenSearch = struct { } assert(history.start_pin.node == history_node); - var results: std.ArrayList(Selection) = try .initCapacity( + var results: std.ArrayList(FlattenedHighlight) = try .initCapacity( alloc, self.history_results.items.len, ); errdefer results.deinit(alloc); - while (window.next()) |sel| { - if (sel.start().node == history_node) continue; - try results.append( - alloc, - sel, - ); + while (window.next()) |hl| { + if (hl.chunks.items(.node)[0] == history_node) continue; + + var hl_cloned = try hl.clone(alloc); + errdefer hl_cloned.deinit(alloc); + try results.append(alloc, hl_cloned); } // If we have no matches then there is nothing to change @@ -380,13 +388,14 @@ pub const ScreenSearch = struct { // Matches! Reverse our list then append all the remaining // history items that didn't start on our original node. - std.mem.reverse(Selection, results.items); + std.mem.reverse(FlattenedHighlight, results.items); try results.appendSlice(alloc, self.history_results.items); self.history_results.deinit(alloc); self.history_results = results; } // Reset our active search results and search again. + for (self.active_results.items) |*hl| hl.deinit(alloc); self.active_results.clearRetainingCapacity(); switch (self.state) { // If we're in the active state we run a normal tick so @@ -425,26 +434,26 @@ test "simple search" { try testing.expectEqual(2, matches.len); { - const sel = matches[0]; + const sel = matches[0].untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 2, - } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 2, - } }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } { - const sel = matches[1]; + const sel = matches[1].untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } @@ -477,15 +486,15 @@ test "simple search with history" { try testing.expectEqual(1, matches.len); { - const sel = matches[0]; + const sel = matches[0].untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } @@ -528,26 +537,26 @@ test "reload active with history change" { defer alloc.free(matches); try testing.expectEqual(2, matches.len); { - const sel = matches[1]; + const sel = matches[1].untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } { - const sel = matches[0]; + const sel = matches[0].untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 1, .y = 1, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 4, .y = 1, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } } @@ -562,15 +571,15 @@ test "reload active with history change" { defer alloc.free(matches); try testing.expectEqual(1, matches.len); { - const sel = matches[0]; + const sel = matches[0].untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 2, .y = 0, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 5, .y = 0, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } } } @@ -603,14 +612,14 @@ test "active change contents" { try testing.expectEqual(1, matches.len); { - const sel = matches[0]; + const sel = matches[0].untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 1, - } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 1, - } }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index b0df3c13b..c1428e35c 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -4,11 +4,13 @@ const Allocator = std.mem.Allocator; const CircBuf = @import("../../datastruct/main.zig").CircBuf; const terminal = @import("../main.zig"); const point = terminal.point; +const size = terminal.size; const PageList = terminal.PageList; const Pin = PageList.Pin; const Selection = terminal.Selection; const Screen = terminal.Screen; const PageFormatter = @import("../formatter.zig").PageFormatter; +const FlattenedHighlight = terminal.highlight.Flattened; /// Searches page nodes via a sliding window. The sliding window maintains /// the invariant that data isn't pruned until (1) we've searched it and @@ -51,6 +53,10 @@ pub const SlidingWindow = struct { /// data to meta. meta: MetaBuf, + /// Buffer that can fit any amount of chunks necessary for next + /// to never fail allocation. + chunk_buf: std.MultiArrayList(FlattenedHighlight.Chunk), + /// Offset into data for our current state. This handles the /// situation where our search moved through meta[0] but didn't /// do enough to prune it. @@ -113,6 +119,7 @@ pub const SlidingWindow = struct { .alloc = alloc, .data = data, .meta = meta, + .chunk_buf = .empty, .needle = needle, .direction = direction, .overlap_buf = overlap_buf, @@ -122,6 +129,7 @@ pub const SlidingWindow = struct { pub fn deinit(self: *SlidingWindow) void { self.alloc.free(self.overlap_buf); self.alloc.free(self.needle); + self.chunk_buf.deinit(self.alloc); self.data.deinit(self.alloc); var meta_it = self.meta.iterator(.forward); @@ -143,14 +151,17 @@ pub const SlidingWindow = struct { /// the invariant that the window is always big enough to contain /// the needle. /// - /// It may seem wasteful to return a full selection, since the needle - /// length is known it seems like we can get away with just returning - /// the start index. However, returning a full selection will give us - /// more flexibility in the future (e.g. if we want to support regex - /// searches or other more complex searches). It does cost us some memory, - /// but searches are expected to be relatively rare compared to normal - /// operations and can eat up some extra memory temporarily. - pub fn next(self: *SlidingWindow) ?Selection { + /// This returns a flattened highlight on a match. The + /// flattened highlight requires allocation and is therefore more expensive + /// than a normal selection, but it is more efficient to render since it + /// has all the information without having to dereference pointers into + /// the terminal state. + /// + /// The flattened highlight chunks reference internal memory for this + /// sliding window and are only valid until the next call to `next()` + /// or `append()`. If the caller wants to retain the flattened highlight + /// then they should clone it. + pub fn next(self: *SlidingWindow) ?FlattenedHighlight { const slices = slices: { // If we have less data then the needle then we can't possibly match const data_len = self.data.len(); @@ -164,7 +175,7 @@ pub const SlidingWindow = struct { // Search the first slice for the needle. if (std.mem.indexOf(u8, slices[0], self.needle)) |idx| { - return self.selection( + return self.highlight( idx, self.needle.len, ); @@ -197,7 +208,7 @@ pub const SlidingWindow = struct { // We found a match in the overlap buffer. We need to map the // index back to the data buffer in order to get our selection. - return self.selection( + return self.highlight( slices[0].len - prefix.len + idx, self.needle.len, ); @@ -205,7 +216,7 @@ pub const SlidingWindow = struct { // Search the last slice for the needle. if (std.mem.indexOf(u8, slices[1], self.needle)) |idx| { - return self.selection( + return self.highlight( slices[0].len + idx, self.needle.len, ); @@ -263,114 +274,230 @@ pub const SlidingWindow = struct { return null; } - /// Return a selection for the given start and length into the data - /// buffer and also prune the data/meta buffers if possible up to - /// this start index. + /// Return a flattened highlight for the given start and length. + /// + /// The flattened highlight can be used to render the highlight + /// in the most efficient way because it doesn't require a terminal + /// lock to access terminal data to compare whether some viewport + /// matches the highlight (because it doesn't need to traverse + /// the page nodes). /// /// The start index is assumed to be relative to the offset. i.e. /// index zero is actually at `self.data[self.data_offset]`. The /// selection will account for the offset. - fn selection( + fn highlight( self: *SlidingWindow, start_offset: usize, len: usize, - ) Selection { + ) terminal.highlight.Flattened { const start = start_offset + self.data_offset; - assert(start < self.data.len()); - assert(start + len <= self.data.len()); + const end = start + len - 1; + if (comptime std.debug.runtime_safety) { + assert(start < self.data.len()); + assert(start + len <= self.data.len()); + } - // meta_consumed is the number of bytes we've consumed in the - // data buffer up to and NOT including the meta where we've - // found our pin. This is important because it tells us the - // amount of data we can safely deleted from self.data since - // we can't partially delete a meta block's data. (The partial - // amount is represented by self.data_offset). - var meta_it = self.meta.iterator(.forward); - var meta_consumed: usize = 0; - const tl: Pin = pin(&meta_it, &meta_consumed, start); + // Clear our previous chunk buffer to store this result + self.chunk_buf.clearRetainingCapacity(); + var result: terminal.highlight.Flattened = .empty; - // Store the information required to prune later. We store this - // now because we only want to prune up to our START so we can - // find overlapping matches. - const tl_meta_idx = meta_it.idx - 1; - const tl_meta_consumed = meta_consumed; + // Go through the meta nodes to find our start. + const tl: struct { + /// If non-null, we need to continue searching for the bottom-right. + br: ?struct { + it: MetaBuf.Iterator, + consumed: usize, + }, - // We have to seek back so that we reinspect our current - // iterator value again in case the start and end are in the - // same segment. - meta_it.seekBy(-1); - const br: Pin = pin(&meta_it, &meta_consumed, start + len - 1); - assert(meta_it.idx >= 1); + /// Data to prune, both are lengths. + prune: struct { + meta: usize, + data: usize, + }, + } = tl: { + var meta_it = self.meta.iterator(.forward); + var meta_consumed: usize = 0; + while (meta_it.next()) |meta| { + // Always increment our consumed count so that our index + // is right for the end search if we do it. + const prior_meta_consumed = meta_consumed; + meta_consumed += meta.cell_map.items.len; + + // meta_i is the index we expect to find the match in the + // cell map within this meta if it contains it. + const meta_i = start - prior_meta_consumed; + + // This meta doesn't contain the match. This means we + // can also prune this set of data because we only look + // forward. + if (meta_i >= meta.cell_map.items.len) continue; + + // Now we look for the end. In MOST cases it is the same as + // our starting chunk because highlights are usually small and + // not on a boundary, so let's optimize for that. + const end_i = end - prior_meta_consumed; + if (end_i < meta.cell_map.items.len) { + @branchHint(.likely); + + // The entire highlight is within this meta. + const start_map = meta.cell_map.items[meta_i]; + const end_map = meta.cell_map.items[end_i]; + result.top_x = start_map.x; + result.bot_x = end_map.x; + self.chunk_buf.appendAssumeCapacity(.{ + .node = meta.node, + .start = @intCast(start_map.y), + .end = @intCast(end_map.y + 1), + }); + + break :tl .{ + .br = null, + .prune = .{ + .meta = meta_it.idx - 1, + .data = prior_meta_consumed, + }, + }; + } else { + // We found the meta that contains the start of the match + // only. Consume this entire node from our start offset. + const map = meta.cell_map.items[meta_i]; + result.top_x = map.x; + self.chunk_buf.appendAssumeCapacity(.{ + .node = meta.node, + .start = @intCast(map.y), + .end = meta.node.data.size.rows, + }); + + break :tl .{ + .br = .{ + .it = meta_it, + .consumed = meta_consumed, + }, + .prune = .{ + .meta = meta_it.idx - 1, + .data = prior_meta_consumed, + }, + }; + } + } else { + // Precondition that the start index is within the data buffer. + unreachable; + } + }; + + // Search for our end. + if (tl.br) |br| { + var meta_it = br.it; + var meta_consumed: usize = br.consumed; + while (meta_it.next()) |meta| { + // meta_i is the index we expect to find the match in the + // cell map within this meta if it contains it. + const meta_i = end - meta_consumed; + if (meta_i >= meta.cell_map.items.len) { + // This meta doesn't contain the match. We still add it + // to our results because we want the full flattened list. + self.chunk_buf.appendAssumeCapacity(.{ + .node = meta.node, + .start = 0, + .end = meta.node.data.size.rows, + }); + + meta_consumed += meta.cell_map.items.len; + continue; + } + + // We found it + const map = meta.cell_map.items[meta_i]; + result.bot_x = map.x; + self.chunk_buf.appendAssumeCapacity(.{ + .node = meta.node, + .start = 0, + .end = @intCast(map.y + 1), + }); + break; + } else { + // Precondition that the end index is within the data buffer. + unreachable; + } + } // Our offset into the current meta block is the start index // minus the amount of data fully consumed. We then add one // to move one past the match so we don't repeat it. - self.data_offset = start - tl_meta_consumed + 1; + self.data_offset = start - tl.prune.data + 1; - // meta_it.idx is br's meta index plus one (because the iterator - // moves one past the end; we call next() one last time). So - // we compare against one to check that the meta that we matched - // in has prior meta blocks we can prune. - if (tl_meta_idx > 0) { + // If we went beyond our initial meta node we can prune. + if (tl.prune.meta > 0) { // Deinit all our memory in the meta blocks prior to our // match. - const meta_count = tl_meta_idx; - meta_it.reset(); - for (0..meta_count) |_| meta_it.next().?.deinit(self.alloc); - if (comptime std.debug.runtime_safety) { - assert(meta_it.idx == meta_count); - assert(meta_it.next().?.node == tl.node); + var meta_it = self.meta.iterator(.forward); + var meta_consumed: usize = 0; + for (0..tl.prune.meta) |_| { + const meta: *Meta = meta_it.next().?; + meta_consumed += meta.cell_map.items.len; + meta.deinit(self.alloc); } - self.meta.deleteOldest(meta_count); + if (comptime std.debug.runtime_safety) { + assert(meta_it.idx == tl.prune.meta); + assert(meta_it.next().?.node == self.chunk_buf.items(.node)[0]); + } + self.meta.deleteOldest(tl.prune.meta); // Delete all the data up to our current index. - assert(tl_meta_consumed > 0); - self.data.deleteOldest(tl_meta_consumed); + assert(tl.prune.data > 0); + self.data.deleteOldest(tl.prune.data); } - self.assertIntegrity(); - return switch (self.direction) { - .forward => .init(tl, br, false), - .reverse => .init(br, tl, false), - }; - } + switch (self.direction) { + .forward => {}, + .reverse => { + if (self.chunk_buf.len > 1) { + // Reverse all our chunks. This should be pretty obvious why. + const slice = self.chunk_buf.slice(); + const nodes = slice.items(.node); + const starts = slice.items(.start); + const ends = slice.items(.end); + std.mem.reverse(*PageList.List.Node, nodes); + std.mem.reverse(size.CellCountInt, starts); + std.mem.reverse(size.CellCountInt, ends); - /// Convert a data index into a pin. - /// - /// The iterator and offset are both expected to be passed by - /// pointer so that the pin can be efficiently called for multiple - /// indexes (in order). See selection() for an example. - /// - /// Precondition: the index must be within the data buffer. - fn pin( - it: *MetaBuf.Iterator, - offset: *usize, - idx: usize, - ) Pin { - while (it.next()) |meta| { - // meta_i is the index we expect to find the match in the - // cell map within this meta if it contains it. - const meta_i = idx - offset.*; - if (meta_i >= meta.cell_map.items.len) { - // This meta doesn't contain the match. This means we - // can also prune this set of data because we only look - // forward. - offset.* += meta.cell_map.items.len; - continue; - } + // Now normally with forward traversal with multiple pages, + // the suffix of the first page and the prefix of the last + // page are used. + // + // For a reverse traversal, this is inverted (since the + // pages are in reverse order we get the suffix of the last + // page and the prefix of the first page). So we need to + // invert this. + // + // We DON'T need to do this for any middle pages because + // they always use the full page. + // + // We DON'T need to do this for chunks.len == 1 because + // the pages themselves aren't reversed and we don't have + // any prefix/suffix problems. + // + // This is a fixup that makes our start/end match the + // same logic as the loops above if they were in forward + // order. + assert(nodes.len >= 2); + starts[0] = ends[0] - 1; + ends[0] = nodes[0].data.size.rows; + ends[nodes.len - 1] = starts[nodes.len - 1] + 1; + starts[nodes.len - 1] = 0; + } - // We found the meta that contains the start of the match. - const map = meta.cell_map.items[meta_i]; - return .{ - .node = meta.node, - .y = @intCast(map.y), - .x = map.x, - }; + // X values also need to be reversed since the top/bottom + // are swapped for the nodes. + const top_x = result.top_x; + result.top_x = result.bot_x; + result.bot_x = top_x; + }, } - // Unreachable because it is a precondition that the index is - // within the data buffer. - unreachable; + // Copy over our MultiArrayList so it points to the proper memory. + result.chunks = self.chunk_buf; + return result; } /// Add a new node to the sliding window. This will always grow @@ -442,6 +569,7 @@ pub const SlidingWindow = struct { // Ensure our buffers are big enough to store what we need. try self.data.ensureUnusedCapacity(self.alloc, written.len); try self.meta.ensureUnusedCapacity(self.alloc, 1); + try self.chunk_buf.ensureTotalCapacity(self.alloc, self.meta.capacity()); // Append our new node to the circular buffer. self.data.appendSliceAssumeCapacity(written); @@ -505,26 +633,28 @@ test "SlidingWindow single append" { // We should be able to find two matches. { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 7, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start)); try testing.expectEqual(point.Point{ .active = .{ .x = 10, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end)); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 19, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start)); try testing.expectEqual(point.Point{ .active = .{ .x = 22, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end)); } try testing.expect(w.next() == null); try testing.expect(w.next() == null); @@ -582,26 +712,28 @@ test "SlidingWindow two pages" { // Search should find two matches { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 76, .y = 22, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 79, .y = 22, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 7, .y = 23, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 10, .y = 23, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); try testing.expect(w.next() == null); @@ -634,15 +766,16 @@ test "SlidingWindow two pages match across boundary" { // Search should find a match { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 76, .y = 22, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 7, .y = 23, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); try testing.expect(w.next() == null); @@ -831,15 +964,16 @@ test "SlidingWindow single append across circular buffer boundary" { try testing.expect(slices[1].len > 0); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 19, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 21, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); } @@ -889,15 +1023,16 @@ test "SlidingWindow single append match on boundary" { try testing.expect(slices[1].len > 0); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 21, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 1, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); } @@ -920,26 +1055,28 @@ test "SlidingWindow single append reversed" { // We should be able to find two matches. { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 19, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 22, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 7, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 10, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); try testing.expect(w.next() == null); @@ -997,26 +1134,28 @@ test "SlidingWindow two pages reversed" { // Search should find two matches (in reverse order) { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 7, .y = 23, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 10, .y = 23, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 76, .y = 22, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 79, .y = 22, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); try testing.expect(w.next() == null); @@ -1049,15 +1188,16 @@ test "SlidingWindow two pages match across boundary reversed" { // Search should find a match { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 76, .y = 22, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 7, .y = 23, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); try testing.expect(w.next() == null); @@ -1185,15 +1325,16 @@ test "SlidingWindow single append across circular buffer boundary reversed" { try testing.expect(slices[1].len > 0); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 19, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 21, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); } @@ -1244,15 +1385,16 @@ test "SlidingWindow single append match on boundary reversed" { try testing.expect(slices[1].len > 0); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 21, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 1, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); } diff --git a/src/terminal/search/sliding_window2.zig b/src/terminal/search/sliding_window2.zig deleted file mode 100644 index 6aad0bff9..000000000 --- a/src/terminal/search/sliding_window2.zig +++ /dev/null @@ -1,1400 +0,0 @@ -const std = @import("std"); -const assert = @import("../../quirks.zig").inlineAssert; -const Allocator = std.mem.Allocator; -const CircBuf = @import("../../datastruct/main.zig").CircBuf; -const terminal = @import("../main.zig"); -const point = terminal.point; -const size = terminal.size; -const PageList = terminal.PageList; -const Pin = PageList.Pin; -const Selection = terminal.Selection; -const Screen = terminal.Screen; -const PageFormatter = @import("../formatter.zig").PageFormatter; -const FlattenedHighlight = terminal.highlight.Flattened; - -/// Searches page nodes via a sliding window. The sliding window maintains -/// the invariant that data isn't pruned until (1) we've searched it and -/// (2) we've accounted for overlaps across pages to fit the needle. -/// -/// The sliding window is first initialized empty. Pages are then appended -/// in the order to search them. The sliding window supports both a forward -/// and reverse order specified via `init`. The pages should be appended -/// in the correct order matching the search direction. -/// -/// All appends grow the window. The window is only pruned when a search -/// is done (positive or negative match) via `next()`. -/// -/// To avoid unnecessary memory growth, the recommended usage is to -/// call `next()` until it returns null and then `append` the next page -/// and repeat the process. This will always maintain the minimum -/// required memory to search for the needle. -/// -/// The caller is responsible for providing the pages and ensuring they're -/// in the proper order. The SlidingWindow itself doesn't own the pages, but -/// it will contain pointers to them in order to return selections. If any -/// pages become invalid, the caller should clear the sliding window and -/// start over. -pub const SlidingWindow = struct { - /// The allocator to use for all the data within this window. We - /// store this rather than passing it around because its already - /// part of multiple elements (eg. Meta's CellMap) and we want to - /// ensure we always use a consistent allocator. Additionally, only - /// a small amount of sliding windows are expected to be in use - /// at any one time so the memory overhead isn't that large. - alloc: Allocator, - - /// The data buffer is a circular buffer of u8 that contains the - /// encoded page text that we can use to search for the needle. - data: DataBuf, - - /// The meta buffer is a circular buffer that contains the metadata - /// about the pages we're searching. This usually isn't that large - /// so callers must iterate through it to find the offset to map - /// data to meta. - meta: MetaBuf, - - /// Buffer that can fit any amount of chunks necessary for next - /// to never fail allocation. - chunk_buf: std.MultiArrayList(FlattenedHighlight.Chunk), - - /// Offset into data for our current state. This handles the - /// situation where our search moved through meta[0] but didn't - /// do enough to prune it. - data_offset: usize = 0, - - /// The needle we're searching for. Does own the memory. - needle: []const u8, - - /// The search direction. If the direction is forward then pages should - /// be appended in forward linked list order from the PageList. If the - /// direction is reverse then pages should be appended in reverse order. - /// - /// This is important because in most cases, a reverse search is going - /// to be more desirable to search from the end of the active area - /// backwards so more recent data is found first. Supporting both is - /// trivial though and will let us do more complex optimizations in the - /// future (e.g. starting from the viewport and doing a forward/reverse - /// concurrently from that point). - direction: Direction, - - /// A buffer to store the overlap search data. This is used to search - /// overlaps between pages where the match starts on one page and - /// ends on another. The length is always `needle.len * 2`. - overlap_buf: []u8, - - const Direction = enum { forward, reverse }; - const DataBuf = CircBuf(u8, 0); - const MetaBuf = CircBuf(Meta, undefined); - const Meta = struct { - node: *PageList.List.Node, - cell_map: std.ArrayList(point.Coordinate), - - pub fn deinit(self: *Meta, alloc: Allocator) void { - self.cell_map.deinit(alloc); - } - }; - - pub fn init( - alloc: Allocator, - direction: Direction, - needle_unowned: []const u8, - ) Allocator.Error!SlidingWindow { - var data = try DataBuf.init(alloc, 0); - errdefer data.deinit(alloc); - - var meta = try MetaBuf.init(alloc, 0); - errdefer meta.deinit(alloc); - - const needle = try alloc.dupe(u8, needle_unowned); - errdefer alloc.free(needle); - switch (direction) { - .forward => {}, - .reverse => std.mem.reverse(u8, needle), - } - - const overlap_buf = try alloc.alloc(u8, needle.len * 2); - errdefer alloc.free(overlap_buf); - - return .{ - .alloc = alloc, - .data = data, - .meta = meta, - .chunk_buf = .empty, - .needle = needle, - .direction = direction, - .overlap_buf = overlap_buf, - }; - } - - pub fn deinit(self: *SlidingWindow) void { - self.alloc.free(self.overlap_buf); - self.alloc.free(self.needle); - self.chunk_buf.deinit(self.alloc); - self.data.deinit(self.alloc); - - var meta_it = self.meta.iterator(.forward); - while (meta_it.next()) |meta| meta.deinit(self.alloc); - self.meta.deinit(self.alloc); - } - - /// Clear all data but retain allocated capacity. - pub fn clearAndRetainCapacity(self: *SlidingWindow) void { - var meta_it = self.meta.iterator(.forward); - while (meta_it.next()) |meta| meta.deinit(self.alloc); - self.meta.clear(); - self.data.clear(); - self.data_offset = 0; - } - - /// Search the window for the next occurrence of the needle. As - /// the window moves, the window will prune itself while maintaining - /// the invariant that the window is always big enough to contain - /// the needle. - /// - /// This returns a flattened highlight on a match. The - /// flattened highlight requires allocation and is therefore more expensive - /// than a normal selection, but it is more efficient to render since it - /// has all the information without having to dereference pointers into - /// the terminal state. - /// - /// The flattened highlight chunks reference internal memory for this - /// sliding window and are only valid until the next call to `next()` - /// or `append()`. If the caller wants to retain the flattened highlight - /// then they should clone it. - pub fn next(self: *SlidingWindow) ?FlattenedHighlight { - const slices = slices: { - // If we have less data then the needle then we can't possibly match - const data_len = self.data.len(); - if (data_len < self.needle.len) return null; - - break :slices self.data.getPtrSlice( - self.data_offset, - data_len - self.data_offset, - ); - }; - - // Search the first slice for the needle. - if (std.mem.indexOf(u8, slices[0], self.needle)) |idx| { - return self.highlight( - idx, - self.needle.len, - ); - } - - // Search the overlap buffer for the needle. - if (slices[0].len > 0 and slices[1].len > 0) overlap: { - // Get up to needle.len - 1 bytes from each side (as much as - // we can) and store it in the overlap buffer. - const prefix: []const u8 = prefix: { - const len = @min(slices[0].len, self.needle.len - 1); - const idx = slices[0].len - len; - break :prefix slices[0][idx..]; - }; - const suffix: []const u8 = suffix: { - const len = @min(slices[1].len, self.needle.len - 1); - break :suffix slices[1][0..len]; - }; - const overlap_len = prefix.len + suffix.len; - assert(overlap_len <= self.overlap_buf.len); - @memcpy(self.overlap_buf[0..prefix.len], prefix); - @memcpy(self.overlap_buf[prefix.len..overlap_len], suffix); - - // Search the overlap - const idx = std.mem.indexOf( - u8, - self.overlap_buf[0..overlap_len], - self.needle, - ) orelse break :overlap; - - // We found a match in the overlap buffer. We need to map the - // index back to the data buffer in order to get our selection. - return self.highlight( - slices[0].len - prefix.len + idx, - self.needle.len, - ); - } - - // Search the last slice for the needle. - if (std.mem.indexOf(u8, slices[1], self.needle)) |idx| { - return self.highlight( - slices[0].len + idx, - self.needle.len, - ); - } - - // No match. We keep `needle.len - 1` bytes available to - // handle the future overlap case. - var meta_it = self.meta.iterator(.reverse); - prune: { - var saved: usize = 0; - while (meta_it.next()) |meta| { - const needed = self.needle.len - 1 - saved; - if (meta.cell_map.items.len >= needed) { - // We save up to this meta. We set our data offset - // to exactly where it needs to be to continue - // searching. - self.data_offset = meta.cell_map.items.len - needed; - break; - } - - saved += meta.cell_map.items.len; - } else { - // If we exited the while loop naturally then we - // never got the amount we needed and so there is - // nothing to prune. - assert(saved < self.needle.len - 1); - break :prune; - } - - const prune_count = self.meta.len() - meta_it.idx; - if (prune_count == 0) { - // This can happen if we need to save up to the first - // meta value to retain our window. - break :prune; - } - - // We can now delete all the metas up to but NOT including - // the meta we found through meta_it. - meta_it = self.meta.iterator(.forward); - var prune_data_len: usize = 0; - for (0..prune_count) |_| { - const meta = meta_it.next().?; - prune_data_len += meta.cell_map.items.len; - meta.deinit(self.alloc); - } - self.meta.deleteOldest(prune_count); - self.data.deleteOldest(prune_data_len); - } - - // Our data offset now moves to needle.len - 1 from the end so - // that we can handle the overlap case. - self.data_offset = self.data.len() - self.needle.len + 1; - - self.assertIntegrity(); - return null; - } - - /// Return a flattened highlight for the given start and length. - /// - /// The flattened highlight can be used to render the highlight - /// in the most efficent way because it doesn't require a terminal - /// lock to access terminal data to compare whether some viewport - /// matches the highlight (because it doesn't need to traverse - /// the page nodes). - /// - /// The start index is assumed to be relative to the offset. i.e. - /// index zero is actually at `self.data[self.data_offset]`. The - /// selection will account for the offset. - fn highlight( - self: *SlidingWindow, - start_offset: usize, - len: usize, - ) terminal.highlight.Flattened { - const start = start_offset + self.data_offset; - const end = start + len - 1; - if (comptime std.debug.runtime_safety) { - assert(start < self.data.len()); - assert(start + len <= self.data.len()); - } - - // Clear our previous chunk buffer to store this result - self.chunk_buf.clearRetainingCapacity(); - var result: terminal.highlight.Flattened = .empty; - - // Go through the meta nodes to find our start. - const tl: struct { - /// If non-null, we need to continue searching for the bottom-right. - br: ?struct { - it: MetaBuf.Iterator, - consumed: usize, - }, - - /// Data to prune, both are lengths. - prune: struct { - meta: usize, - data: usize, - }, - } = tl: { - var meta_it = self.meta.iterator(.forward); - var meta_consumed: usize = 0; - while (meta_it.next()) |meta| { - // Always increment our consumed count so that our index - // is right for the end search if we do it. - const prior_meta_consumed = meta_consumed; - meta_consumed += meta.cell_map.items.len; - - // meta_i is the index we expect to find the match in the - // cell map within this meta if it contains it. - const meta_i = start - prior_meta_consumed; - - // This meta doesn't contain the match. This means we - // can also prune this set of data because we only look - // forward. - if (meta_i >= meta.cell_map.items.len) continue; - - // Now we look for the end. In MOST cases it is the same as - // our starting chunk because highlights are usually small and - // not on a boundary, so let's optimize for that. - const end_i = end - prior_meta_consumed; - if (end_i < meta.cell_map.items.len) { - @branchHint(.likely); - - // The entire highlight is within this meta. - const start_map = meta.cell_map.items[meta_i]; - const end_map = meta.cell_map.items[end_i]; - result.top_x = start_map.x; - result.bot_x = end_map.x; - self.chunk_buf.appendAssumeCapacity(.{ - .node = meta.node, - .start = @intCast(start_map.y), - .end = @intCast(end_map.y + 1), - }); - - break :tl .{ - .br = null, - .prune = .{ - .meta = meta_it.idx - 1, - .data = prior_meta_consumed, - }, - }; - } else { - // We found the meta that contains the start of the match - // only. Consume this entire node from our start offset. - const map = meta.cell_map.items[meta_i]; - result.top_x = map.x; - self.chunk_buf.appendAssumeCapacity(.{ - .node = meta.node, - .start = @intCast(map.y), - .end = meta.node.data.size.rows, - }); - - break :tl .{ - .br = .{ - .it = meta_it, - .consumed = meta_consumed, - }, - .prune = .{ - .meta = meta_it.idx - 1, - .data = prior_meta_consumed, - }, - }; - } - } else { - // Precondition that the start index is within the data buffer. - unreachable; - } - }; - - // Search for our end. - if (tl.br) |br| { - var meta_it = br.it; - var meta_consumed: usize = br.consumed; - while (meta_it.next()) |meta| { - // meta_i is the index we expect to find the match in the - // cell map within this meta if it contains it. - const meta_i = end - meta_consumed; - if (meta_i >= meta.cell_map.items.len) { - // This meta doesn't contain the match. We still add it - // to our results because we want the full flattened list. - self.chunk_buf.appendAssumeCapacity(.{ - .node = meta.node, - .start = 0, - .end = meta.node.data.size.rows, - }); - - meta_consumed += meta.cell_map.items.len; - continue; - } - - // We found it - const map = meta.cell_map.items[meta_i]; - result.bot_x = map.x; - self.chunk_buf.appendAssumeCapacity(.{ - .node = meta.node, - .start = 0, - .end = @intCast(map.y + 1), - }); - break; - } else { - // Precondition that the end index is within the data buffer. - unreachable; - } - } - - // Our offset into the current meta block is the start index - // minus the amount of data fully consumed. We then add one - // to move one past the match so we don't repeat it. - self.data_offset = start - tl.prune.data + 1; - - // If we went beyond our initial meta node we can prune. - if (tl.prune.meta > 0) { - // Deinit all our memory in the meta blocks prior to our - // match. - var meta_it = self.meta.iterator(.forward); - var meta_consumed: usize = 0; - for (0..tl.prune.meta) |_| { - const meta: *Meta = meta_it.next().?; - meta_consumed += meta.cell_map.items.len; - meta.deinit(self.alloc); - } - if (comptime std.debug.runtime_safety) { - assert(meta_it.idx == tl.prune.meta); - assert(meta_it.next().?.node == self.chunk_buf.items(.node)[0]); - } - self.meta.deleteOldest(tl.prune.meta); - - // Delete all the data up to our current index. - assert(tl.prune.data > 0); - self.data.deleteOldest(tl.prune.data); - } - - switch (self.direction) { - .forward => {}, - .reverse => { - if (self.chunk_buf.len > 1) { - // Reverse all our chunks. This should be pretty obvious why. - const slice = self.chunk_buf.slice(); - const nodes = slice.items(.node); - const starts = slice.items(.start); - const ends = slice.items(.end); - std.mem.reverse(*PageList.List.Node, nodes); - std.mem.reverse(size.CellCountInt, starts); - std.mem.reverse(size.CellCountInt, ends); - - // Now normally with forward traversal with multiple pages, - // the suffix of the first page and the prefix of the last - // page are used. - // - // For a reverse traversal, this is inverted (since the - // pages are in reverse order we get the suffix of the last - // page and the prefix of the first page). So we need to - // invert this. - // - // We DON'T need to do this for any middle pages because - // they always use the full page. - // - // We DON'T need to do this for chunks.len == 1 because - // the pages themselves aren't reversed and we don't have - // any prefix/suffix problems. - // - // This is a fixup that makes our start/end match the - // same logic as the loops above if they were in forward - // order. - assert(nodes.len >= 2); - starts[0] = ends[0] - 1; - ends[0] = nodes[0].data.size.rows; - ends[nodes.len - 1] = starts[nodes.len - 1] + 1; - starts[nodes.len - 1] = 0; - } - - // X values also need to be reversed since the top/bottom - // are swapped for the nodes. - const top_x = result.top_x; - result.top_x = result.bot_x; - result.bot_x = top_x; - }, - } - - // Copy over our MultiArrayList so it points to the proper memory. - result.chunks = self.chunk_buf; - return result; - } - - /// Add a new node to the sliding window. This will always grow - /// the sliding window; data isn't pruned until it is consumed - /// via a search (via next()). - /// - /// Returns the number of bytes of content added to the sliding window. - /// The total bytes will be larger since this omits metadata, but it is - /// an accurate measure of the text content size added. - pub fn append( - self: *SlidingWindow, - node: *PageList.List.Node, - ) Allocator.Error!usize { - // Initialize our metadata for the node. - var meta: Meta = .{ - .node = node, - .cell_map = .empty, - }; - errdefer meta.deinit(self.alloc); - - // This is suboptimal but we need to encode the page once to - // temporary memory, and then copy it into our circular buffer. - // In the future, we should benchmark and see if we can encode - // directly into the circular buffer. - var encoded: std.Io.Writer.Allocating = .init(self.alloc); - defer encoded.deinit(); - - // Encode the page into the buffer. - const formatter: PageFormatter = formatter: { - var formatter: PageFormatter = .init(&meta.node.data, .plain); - formatter.point_map = .{ - .alloc = self.alloc, - .map = &meta.cell_map, - }; - break :formatter formatter; - }; - formatter.format(&encoded.writer) catch { - // writer uses anyerror but the only realistic error on - // an ArrayList is out of memory. - return error.OutOfMemory; - }; - assert(meta.cell_map.items.len == encoded.written().len); - - // If the node we're adding isn't soft-wrapped, we add the - // trailing newline. - const row = node.data.getRow(node.data.size.rows - 1); - if (!row.wrap) { - encoded.writer.writeByte('\n') catch return error.OutOfMemory; - try meta.cell_map.append( - self.alloc, - meta.cell_map.getLastOrNull() orelse .{ - .x = 0, - .y = 0, - }, - ); - } - - // Get our written data. If we're doing a reverse search then we - // need to reverse all our encodings. - const written = encoded.written(); - switch (self.direction) { - .forward => {}, - .reverse => { - std.mem.reverse(u8, written); - std.mem.reverse(point.Coordinate, meta.cell_map.items); - }, - } - - // Ensure our buffers are big enough to store what we need. - try self.data.ensureUnusedCapacity(self.alloc, written.len); - try self.meta.ensureUnusedCapacity(self.alloc, 1); - try self.chunk_buf.ensureTotalCapacity(self.alloc, self.meta.capacity()); - - // Append our new node to the circular buffer. - self.data.appendSliceAssumeCapacity(written); - self.meta.appendAssumeCapacity(meta); - - self.assertIntegrity(); - return written.len; - } - - /// Only for tests! - fn testChangeNeedle(self: *SlidingWindow, new: []const u8) void { - assert(new.len == self.needle.len); - self.alloc.free(self.needle); - self.needle = self.alloc.dupe(u8, new) catch unreachable; - } - - fn assertIntegrity(self: *const SlidingWindow) void { - if (comptime !std.debug.runtime_safety) return; - - // We don't run integrity checks on Valgrind because its soooooo slow, - // Valgrind is our integrity checker, and we run these during unit - // tests (non-Valgrind) anyways so we're verifying anyways. - if (std.valgrind.runningOnValgrind() > 0) return; - - // Integrity check: verify our data matches our metadata exactly. - var meta_it = self.meta.iterator(.forward); - var data_len: usize = 0; - while (meta_it.next()) |m| data_len += m.cell_map.items.len; - assert(data_len == self.data.len()); - - // Integrity check: verify our data offset is within bounds. - assert(self.data_offset < self.data.len()); - } -}; - -test "SlidingWindow empty on init" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .forward, "boo!"); - defer w.deinit(); - try testing.expectEqual(0, w.data.len()); - try testing.expectEqual(0, w.meta.len()); -} - -test "SlidingWindow single append" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .forward, "boo!"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); - defer s.deinit(); - try s.testWriteString("hello. boo! hello. boo!"); - - // We want to test single-page cases. - try testing.expect(s.pages.pages.first == s.pages.pages.last); - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node); - - // We should be able to find two matches. - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 7, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start)); - try testing.expectEqual(point.Point{ .active = .{ - .x = 10, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.end)); - } - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 19, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start)); - try testing.expectEqual(point.Point{ .active = .{ - .x = 22, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.end)); - } - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); -} - -test "SlidingWindow single append no match" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .forward, "nope!"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); - defer s.deinit(); - try s.testWriteString("hello. boo! hello. boo!"); - - // We want to test single-page cases. - try testing.expect(s.pages.pages.first == s.pages.pages.last); - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node); - - // No matches - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); - - // Should still keep the page - try testing.expectEqual(1, w.meta.len()); -} - -test "SlidingWindow two pages" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .forward, "boo!"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); - defer s.deinit(); - - // Fill up the first page. The final bytes in the first page - // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); - for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); - try s.testWriteString("boo!"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - try s.testWriteString("\n"); - try testing.expect(s.pages.pages.first != s.pages.pages.last); - try s.testWriteString("hello. boo!"); - - // Add both pages - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node); - _ = try w.append(node.next.?); - - // Search should find two matches - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 76, - .y = 22, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 79, - .y = 22, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 7, - .y = 23, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 10, - .y = 23, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); -} - -test "SlidingWindow two pages match across boundary" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .forward, "hello, world"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); - defer s.deinit(); - - // Fill up the first page. The final bytes in the first page - // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); - for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); - try s.testWriteString("hell"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - try s.testWriteString("o, world!"); - try testing.expect(s.pages.pages.first != s.pages.pages.last); - - // Add both pages - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node); - _ = try w.append(node.next.?); - - // Search should find a match - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 76, - .y = 22, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 7, - .y = 23, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); - - // We shouldn't prune because we don't have enough space - try testing.expectEqual(2, w.meta.len()); -} - -test "SlidingWindow two pages no match across boundary with newline" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .forward, "hello, world"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); - defer s.deinit(); - - // Fill up the first page. The final bytes in the first page - // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); - for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); - try s.testWriteString("hell"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - try s.testWriteString("\no, world!"); - try testing.expect(s.pages.pages.first != s.pages.pages.last); - - // Add both pages - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node); - _ = try w.append(node.next.?); - - // Search should NOT find a match - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); - - // We shouldn't prune because we don't have enough space - try testing.expectEqual(2, w.meta.len()); -} - -test "SlidingWindow two pages no match across boundary with newline reverse" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .reverse, "hello, world"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); - defer s.deinit(); - - // Fill up the first page. The final bytes in the first page - // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); - for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); - try s.testWriteString("hell"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - try s.testWriteString("\no, world!"); - try testing.expect(s.pages.pages.first != s.pages.pages.last); - - // Add both pages in reverse order - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node.next.?); - _ = try w.append(node); - - // Search should NOT find a match - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); -} - -test "SlidingWindow two pages no match prunes first page" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .forward, "nope!"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); - defer s.deinit(); - - // Fill up the first page. The final bytes in the first page - // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); - for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); - try s.testWriteString("boo!"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - try s.testWriteString("\n"); - try testing.expect(s.pages.pages.first != s.pages.pages.last); - try s.testWriteString("hello. boo!"); - - // Add both pages - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node); - _ = try w.append(node.next.?); - - // Search should find nothing - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); - - // We should've pruned our page because the second page - // has enough text to contain our needle. - try testing.expectEqual(1, w.meta.len()); -} - -test "SlidingWindow two pages no match keeps both pages" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); - defer s.deinit(); - - // Fill up the first page. The final bytes in the first page - // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); - for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); - try s.testWriteString("boo!"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - try s.testWriteString("\n"); - try testing.expect(s.pages.pages.first != s.pages.pages.last); - try s.testWriteString("hello. boo!"); - - // Imaginary needle for search. Doesn't match! - var needle_list: std.ArrayList(u8) = .empty; - defer needle_list.deinit(alloc); - try needle_list.appendNTimes(alloc, 'x', first_page_rows * s.pages.cols); - const needle: []const u8 = needle_list.items; - - var w: SlidingWindow = try .init(alloc, .forward, needle); - defer w.deinit(); - - // Add both pages - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node); - _ = try w.append(node.next.?); - - // Search should find nothing - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); - - // No pruning because both pages are needed to fit needle. - try testing.expectEqual(2, w.meta.len()); -} - -test "SlidingWindow single append across circular buffer boundary" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .forward, "abc"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); - defer s.deinit(); - try s.testWriteString("XXXXXXXXXXXXXXXXXXXboo!XXXXX"); - - // We are trying to break a circular buffer boundary so the way we - // do this is to duplicate the data then do a failing search. This - // will cause the first page to be pruned. The next time we append we'll - // put it in the middle of the circ buffer. We assert this so that if - // our implementation changes our test will fail. - try testing.expect(s.pages.pages.first == s.pages.pages.last); - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node); - _ = try w.append(node); - { - // No wrap around yet - const slices = w.data.getPtrSlice(0, w.data.len()); - try testing.expect(slices[0].len > 0); - try testing.expect(slices[1].len == 0); - } - - // Search non-match, prunes page - try testing.expect(w.next() == null); - try testing.expectEqual(1, w.meta.len()); - - // Change the needle, just needs to be the same length (not a real API) - w.testChangeNeedle("boo"); - - // Add new page, now wraps - _ = try w.append(node); - { - const slices = w.data.getPtrSlice(0, w.data.len()); - try testing.expect(slices[0].len > 0); - try testing.expect(slices[1].len > 0); - } - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 19, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 21, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - try testing.expect(w.next() == null); -} - -test "SlidingWindow single append match on boundary" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .forward, "abcd"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); - defer s.deinit(); - try s.testWriteString("o!XXXXXXXXXXXXXXXXXXXbo"); - - // We need to surgically modify the last row to be soft-wrapped - try testing.expect(s.pages.pages.first == s.pages.pages.last); - const node: *PageList.List.Node = s.pages.pages.first.?; - node.data.getRow(node.data.size.rows - 1).wrap = true; - - // We are trying to break a circular buffer boundary so the way we - // do this is to duplicate the data then do a failing search. This - // will cause the first page to be pruned. The next time we append we'll - // put it in the middle of the circ buffer. We assert this so that if - // our implementation changes our test will fail. - _ = try w.append(node); - _ = try w.append(node); - { - // No wrap around yet - const slices = w.data.getPtrSlice(0, w.data.len()); - try testing.expect(slices[0].len > 0); - try testing.expect(slices[1].len == 0); - } - - // Search non-match, prunes page - try testing.expect(w.next() == null); - try testing.expectEqual(1, w.meta.len()); - - // Change the needle, just needs to be the same length (not a real API) - w.testChangeNeedle("boo!"); - - // Add new page, now wraps - _ = try w.append(node); - { - const slices = w.data.getPtrSlice(0, w.data.len()); - try testing.expect(slices[0].len > 0); - try testing.expect(slices[1].len > 0); - } - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 21, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 1, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - try testing.expect(w.next() == null); -} - -test "SlidingWindow single append reversed" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .reverse, "boo!"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); - defer s.deinit(); - try s.testWriteString("hello. boo! hello. boo!"); - - // We want to test single-page cases. - try testing.expect(s.pages.pages.first == s.pages.pages.last); - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node); - - // We should be able to find two matches. - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 19, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 22, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 7, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 10, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); -} - -test "SlidingWindow single append no match reversed" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .reverse, "nope!"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); - defer s.deinit(); - try s.testWriteString("hello. boo! hello. boo!"); - - // We want to test single-page cases. - try testing.expect(s.pages.pages.first == s.pages.pages.last); - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node); - - // No matches - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); - - // Should still keep the page - try testing.expectEqual(1, w.meta.len()); -} - -test "SlidingWindow two pages reversed" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .reverse, "boo!"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); - defer s.deinit(); - - // Fill up the first page. The final bytes in the first page - // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); - for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); - try s.testWriteString("boo!"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - try s.testWriteString("\n"); - try testing.expect(s.pages.pages.first != s.pages.pages.last); - try s.testWriteString("hello. boo!"); - - // Add both pages in reverse order - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node.next.?); - _ = try w.append(node); - - // Search should find two matches (in reverse order) - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 7, - .y = 23, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 10, - .y = 23, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 76, - .y = 22, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 79, - .y = 22, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); -} - -test "SlidingWindow two pages match across boundary reversed" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .reverse, "hello, world"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); - defer s.deinit(); - - // Fill up the first page. The final bytes in the first page - // are "hell" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); - for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); - try s.testWriteString("hell"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - try s.testWriteString("o, world!"); - try testing.expect(s.pages.pages.first != s.pages.pages.last); - - // Add both pages in reverse order - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node.next.?); - _ = try w.append(node); - - // Search should find a match - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 76, - .y = 22, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 7, - .y = 23, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); - - // In reverse mode, the last appended meta (first original page) is large - // enough to contain needle.len - 1 bytes, so pruning occurs - try testing.expectEqual(1, w.meta.len()); -} - -test "SlidingWindow two pages no match prunes first page reversed" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .reverse, "nope!"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); - defer s.deinit(); - - // Fill up the first page. The final bytes in the first page - // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); - for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); - try s.testWriteString("boo!"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - try s.testWriteString("\n"); - try testing.expect(s.pages.pages.first != s.pages.pages.last); - try s.testWriteString("hello. boo!"); - - // Add both pages in reverse order - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node.next.?); - _ = try w.append(node); - - // Search should find nothing - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); - - // We should've pruned our page because the second page - // has enough text to contain our needle. - try testing.expectEqual(1, w.meta.len()); -} - -test "SlidingWindow two pages no match keeps both pages reversed" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); - defer s.deinit(); - - // Fill up the first page. The final bytes in the first page - // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); - for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); - try s.testWriteString("boo!"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - try s.testWriteString("\n"); - try testing.expect(s.pages.pages.first != s.pages.pages.last); - try s.testWriteString("hello. boo!"); - - // Imaginary needle for search. Doesn't match! - var needle_list: std.ArrayList(u8) = .empty; - defer needle_list.deinit(alloc); - try needle_list.appendNTimes(alloc, 'x', first_page_rows * s.pages.cols); - const needle: []const u8 = needle_list.items; - - var w: SlidingWindow = try .init(alloc, .reverse, needle); - defer w.deinit(); - - // Add both pages in reverse order - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node.next.?); - _ = try w.append(node); - - // Search should find nothing - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); - - // No pruning because both pages are needed to fit needle. - try testing.expectEqual(2, w.meta.len()); -} - -test "SlidingWindow single append across circular buffer boundary reversed" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .reverse, "abc"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); - defer s.deinit(); - try s.testWriteString("XXXXXXXXXXXXXXXXXXXboo!XXXXX"); - - // We are trying to break a circular buffer boundary so the way we - // do this is to duplicate the data then do a failing search. This - // will cause the first page to be pruned. The next time we append we'll - // put it in the middle of the circ buffer. We assert this so that if - // our implementation changes our test will fail. - try testing.expect(s.pages.pages.first == s.pages.pages.last); - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node); - _ = try w.append(node); - { - // No wrap around yet - const slices = w.data.getPtrSlice(0, w.data.len()); - try testing.expect(slices[0].len > 0); - try testing.expect(slices[1].len == 0); - } - - // Search non-match, prunes page - try testing.expect(w.next() == null); - try testing.expectEqual(1, w.meta.len()); - - // Change the needle, just needs to be the same length (not a real API) - // testChangeNeedle doesn't reverse, so pass reversed needle for reverse mode - w.testChangeNeedle("oob"); - - // Add new page, now wraps - _ = try w.append(node); - { - const slices = w.data.getPtrSlice(0, w.data.len()); - try testing.expect(slices[0].len > 0); - try testing.expect(slices[1].len > 0); - } - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 19, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 21, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - try testing.expect(w.next() == null); -} - -test "SlidingWindow single append match on boundary reversed" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .reverse, "abcd"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); - defer s.deinit(); - try s.testWriteString("o!XXXXXXXXXXXXXXXXXXXbo"); - - // We need to surgically modify the last row to be soft-wrapped - try testing.expect(s.pages.pages.first == s.pages.pages.last); - const node: *PageList.List.Node = s.pages.pages.first.?; - node.data.getRow(node.data.size.rows - 1).wrap = true; - - // We are trying to break a circular buffer boundary so the way we - // do this is to duplicate the data then do a failing search. This - // will cause the first page to be pruned. The next time we append we'll - // put it in the middle of the circ buffer. We assert this so that if - // our implementation changes our test will fail. - _ = try w.append(node); - _ = try w.append(node); - { - // No wrap around yet - const slices = w.data.getPtrSlice(0, w.data.len()); - try testing.expect(slices[0].len > 0); - try testing.expect(slices[1].len == 0); - } - - // Search non-match, prunes page - try testing.expect(w.next() == null); - try testing.expectEqual(1, w.meta.len()); - - // Change the needle, just needs to be the same length (not a real API) - // testChangeNeedle doesn't reverse, so pass reversed needle for reverse mode - w.testChangeNeedle("!oob"); - - // Add new page, now wraps - _ = try w.append(node); - { - const slices = w.data.getPtrSlice(0, w.data.len()); - try testing.expect(slices[0].len > 0); - try testing.expect(slices[1].len > 0); - } - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 21, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 1, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - try testing.expect(w.next() == null); -} diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index 70fc3088f..9d9cb754b 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -4,6 +4,7 @@ const testing = std.testing; const Allocator = std.mem.Allocator; const point = @import("../point.zig"); const size = @import("../size.zig"); +const FlattenedHighlight = @import("../highlight.zig").Flattened; const PageList = @import("../PageList.zig"); const Selection = @import("../Selection.zig"); const SlidingWindow = @import("sliding_window.zig").SlidingWindow; @@ -150,7 +151,7 @@ pub const ViewportSearch = struct { /// Find the next match for the needle in the active area. This returns /// null when there are no more matches. - pub fn next(self: *ViewportSearch) ?Selection { + pub fn next(self: *ViewportSearch) ?FlattenedHighlight { return self.window.next(); } @@ -207,26 +208,28 @@ test "simple search" { try testing.expect(try search.update(&t.screens.active.pages)); { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 0, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 2, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 2, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } try testing.expect(search.next() == null); } @@ -250,15 +253,16 @@ test "clear screen and search" { try testing.expect(try search.update(&t.screens.active.pages)); { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 1, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 1, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } try testing.expect(search.next() == null); } @@ -289,15 +293,16 @@ test "history search, no active area" { try testing.expect(try search.update(&t.screens.active.pages)); { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } try testing.expect(search.next() == null); From e49f4a6dbcc410331f5d0783e2981cfc7c4fab94 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 15 Nov 2025 20:02:35 -0800 Subject: [PATCH 102/209] `search` binding action starts a search thread on surface --- src/Surface.zig | 75 ++++++++++++++++++++++++++++++++++ src/input/Binding.zig | 5 +++ src/input/command.zig | 1 + src/terminal/search/Thread.zig | 3 ++ 4 files changed, 84 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index 63af42680..6189aae8e 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -155,6 +155,9 @@ selection_scroll_active: bool = false, /// the wall clock time that has elapsed between timestamps. command_timer: ?std.time.Instant = null, +/// Search state +search: ?Search = null, + /// The effect of an input event. This can be used by callers to take /// the appropriate action after an input event. For example, key /// input can be forwarded to the OS for further processing if it @@ -174,6 +177,26 @@ pub const InputEffect = enum { closed, }; +/// The search state for the surface. +const Search = struct { + state: terminal.search.Thread, + thread: std.Thread, + + pub fn deinit(self: *Search) void { + // Notify the thread to stop + self.state.stop.notify() catch |err| log.err( + "error notifying search thread to stop, may stall err={}", + .{err}, + ); + + // Wait for the OS thread to quit + self.thread.join(); + + // Now it is safe to deinit the state + self.state.deinit(); + } +}; + /// Mouse state for the surface. const Mouse = struct { /// The last tracked mouse button state by button. @@ -728,6 +751,9 @@ pub fn init( } pub fn deinit(self: *Surface) void { + // Stop search thread + if (self.search) |*s| s.deinit(); + // Stop rendering thread { self.renderer_thread.stop.notify() catch |err| @@ -1301,6 +1327,12 @@ fn reportColorScheme(self: *Surface, force: bool) void { self.io.queueMessage(.{ .write_stable = output }, .unlocked); } +fn searchCallback(event: terminal.search.Thread.Event, ud: ?*anyopaque) void { + const self: *Surface = @ptrCast(@alignCast(ud.?)); + _ = self; + _ = event; +} + /// Call this when modifiers change. This is safe to call even if modifiers /// match the previous state. /// @@ -4770,6 +4802,49 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool self.renderer_state.terminal.fullReset(); }, + .search => |text| search: { + const s: *Search = if (self.search) |*s| s else init: { + // If we're stopping the search and we had no prior search, + // then there is nothing to do. + if (text.len == 0) break :search; + + // We need to assign directly to self.search because we need + // a stable pointer back to the thread state. + self.search = .{ + .state = try .init(self.alloc, .{ + .mutex = self.renderer_state.mutex, + .terminal = self.renderer_state.terminal, + .event_cb = &searchCallback, + .event_userdata = self, + }), + .thread = undefined, + }; + const s: *Search = &self.search.?; + errdefer s.state.deinit(); + + s.thread = try .spawn( + .{}, + terminal.search.Thread.threadMain, + .{&s.state}, + ); + s.thread.setName("search") catch {}; + + break :init s; + }; + + // Zero-length text means stop searching. + if (text.len == 0) { + s.deinit(); + self.search = null; + break :search; + } + + _ = s.state.mailbox.push( + .{ .change_needle = text }, + .forever, + ); + }, + .copy_to_clipboard => |format| { // We can read from the renderer state without holding // the lock because only we will write to this field. diff --git a/src/input/Binding.zig b/src/input/Binding.zig index c9f3a7343..1b681e725 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -332,6 +332,10 @@ pub const Action = union(enum) { /// to 14.5 points. set_font_size: f32, + /// Start a search for the given text. If the text is empty, then + /// the search is canceled. If a previous search is active, it is replaced. + search: []const u8, + /// Clear the screen and all scrollback. clear_screen, @@ -1152,6 +1156,7 @@ pub const Action = union(enum) { .esc, .text, .cursor_key, + .search, .reset, .copy_to_clipboard, .copy_url_to_clipboard, diff --git a/src/input/command.zig b/src/input/command.zig index b6f75080d..11f65cea3 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -604,6 +604,7 @@ fn actionCommands(action: Action.Key) []const Command { .csi, .esc, .cursor_key, + .search, .set_font_size, .scroll_to_row, .scroll_page_fractional, diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index fdd5f81bc..a35d658b3 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -591,6 +591,7 @@ const Search = struct { // Check our total match data const total = screen_search.matchesLen(); if (total != self.last_total) { + log.debug("notifying total matches={}", .{total}); self.last_total = total; cb(.{ .total_matches = total }, ud); } @@ -626,11 +627,13 @@ const Search = struct { }; } + log.debug("notifying viewport matches len={}", .{results.items.len}); cb(.{ .viewport_matches = results.items }, ud); } // Send our complete notification if we just completed. if (!self.last_complete and self.isComplete()) { + log.debug("notifying search complete", .{}); self.last_complete = true; cb(.complete, ud); } From 72921741e8805415130fbe8cf664e2c16a20b5ec Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 15 Nov 2025 20:28:45 -0800 Subject: [PATCH 103/209] terminal: search.viewport supports dirty tracking for more efficient --- src/terminal/search/viewport.zig | 90 ++++++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 11 deletions(-) diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index 9d9cb754b..0f479b811 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -27,6 +27,12 @@ pub const ViewportSearch = struct { window: SlidingWindow, fingerprint: ?Fingerprint, + /// If this is null, then active dirty tracking is disabled and if the + /// viewport overlaps the active area we always re-search. If this is + /// non-null, then we only re-search if the active area is dirty. Dirty + /// marking is up to the caller. + active_dirty: ?bool, + pub fn init( alloc: Allocator, needle_unowned: []const u8, @@ -36,7 +42,11 @@ pub const ViewportSearch = struct { // a small amount of work to reverse things. var window: SlidingWindow = try .init(alloc, .forward, needle_unowned); errdefer window.deinit(); - return .{ .window = window, .fingerprint = null }; + return .{ + .window = window, + .fingerprint = null, + .active_dirty = null, + }; } pub fn deinit(self: *ViewportSearch) void { @@ -75,17 +85,29 @@ pub const ViewportSearch = struct { var fingerprint: Fingerprint = try .init(self.window.alloc, list); if (self.fingerprint) |*old| { if (old.eql(fingerprint)) match: { - // If our fingerprint contains the active area, then we always - // re-search since the active area is mutable. - const active_tl = list.getTopLeft(.active); - const active_br = list.getBottomRight(.active).?; + // Determine if we need to check if we overlap the active + // area. If we have dirty tracking on we also set it to + // false here. + const check_active: bool = active: { + const dirty = self.active_dirty orelse break :active true; + if (!dirty) break :active false; + self.active_dirty = false; + break :active true; + }; - // If our viewport contains the start or end of the active area, - // we are in the active area. We purposely do this first - // because our viewport is always larger than the active area. - for (old.nodes) |node| { - if (node == active_tl.node) break :match; - if (node == active_br.node) break :match; + if (check_active) { + // If our fingerprint contains the active area, then we always + // re-search since the active area is mutable. + const active_tl = list.getTopLeft(.active); + const active_br = list.getBottomRight(.active).?; + + // If our viewport contains the start or end of the active area, + // we are in the active area. We purposely do this first + // because our viewport is always larger than the active area. + for (old.nodes) |node| { + if (node == active_tl.node) break :match; + if (node == active_br.node) break :match; + } } // No change @@ -267,6 +289,52 @@ test "clear screen and search" { try testing.expect(search.next() == null); } +test "clear screen and search dirty tracking" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: ViewportSearch = try .init(alloc, "Fizz"); + defer search.deinit(); + + // Turn on dirty tracking + search.active_dirty = false; + + // Should update since we've never searched before + try testing.expect(try search.update(&t.screens.active.pages)); + + // Should not update since nothing changed + try testing.expect(!try search.update(&t.screens.active.pages)); + + try s.nextSlice("\x1b[2J"); // Clear screen + try s.nextSlice("\x1b[H"); // Move cursor home + try s.nextSlice("Buzz\r\nFizz\r\nBuzz"); + + // Should still not update since active area isn't dirty + try testing.expect(!try search.update(&t.screens.active.pages)); + + // Mark + search.active_dirty = true; + try testing.expect(try search.update(&t.screens.active.pages)); + + { + const sel = search.next().?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } + try testing.expect(search.next() == null); +} + test "history search, no active area" { const alloc = testing.allocator; var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 }); From 061d157b503115eda4df9b1e3886de6fb8471f20 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 15 Nov 2025 20:47:42 -0800 Subject: [PATCH 104/209] terminal: search should use active area dirty tracking --- src/terminal/Terminal.zig | 10 ++++++++++ src/terminal/search/Thread.zig | 13 +++++++++++++ src/terminal/search/viewport.zig | 4 ++++ 3 files changed, 27 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index e75fd731a..68919107b 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -112,6 +112,16 @@ flags: packed struct { /// True if the terminal should perform selection scrolling. selection_scroll: bool = false, + /// Dirty flag used only by the search thread. The renderer is expected + /// to set this to true if the viewport was dirty as it was rendering. + /// This is used by the search thread to more efficiently re-search the + /// viewport and active area. + /// + /// Since the renderer is going to inspect the viewport/active area ANYWAYS, + /// this lets our search thread do less work and hold the lock less time, + /// resulting in more throughput for everything. + search_viewport_dirty: bool = false, + /// Dirty flags for the renderer. dirty: Dirty = .{}, } = .{}, diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index a35d658b3..7ca9df0b7 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -419,6 +419,10 @@ const Search = struct { var vp: ViewportSearch = try .init(alloc, needle); errdefer vp.deinit(); + // We use dirty tracking for active area changes. Start with it + // dirty so the first change is re-searched. + vp.active_dirty = true; + return .{ .viewport = vp, .screens = .init(.{}), @@ -553,6 +557,15 @@ const Search = struct { } } + // See the `search_viewport_dirty` flag on the terminal to know + // what exactly this is for. But, if this is set, we know the renderer + // found the viewport/active area dirty, so we should mark it as + // dirty in our viewport searcher so it forces a re-search. + if (t.flags.search_viewport_dirty) { + self.viewport.active_dirty = true; + t.flags.search_viewport_dirty = false; + } + // Check our viewport for changes. if (self.viewport.update(&t.screens.active.pages)) |updated| { if (updated) self.stale_viewport_matches = true; diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index 0f479b811..6a266f47a 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -125,6 +125,10 @@ pub const ViewportSearch = struct { self.fingerprint = null; } + // If our active area was set as dirty, we always unset it here + // because we're re-searching now. + if (self.active_dirty) |*v| v.* = false; + // Clear our previous sliding window self.window.clearAndRetainCapacity(); From 6c8ffb5fc1e741b427fa7acc854570e208af7c67 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 16 Nov 2025 07:05:32 -0800 Subject: [PATCH 105/209] renderer: receive message with viewport match selections Doesn't draw yet --- src/Surface.zig | 39 +++++++++++++++++++++++++++++++++++++-- src/renderer/Thread.zig | 8 ++++++++ src/renderer/generic.zig | 14 +++++++++++++- src/renderer/message.zig | 15 +++++++++++++-- 4 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 6189aae8e..cfc0b14aa 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1328,9 +1328,44 @@ fn reportColorScheme(self: *Surface, force: bool) void { } fn searchCallback(event: terminal.search.Thread.Event, ud: ?*anyopaque) void { + // IMPORTANT: This function is run on the SEARCH THREAD! It is NOT SAFE + // to access anything other than values that never change on the surface. + // The surface is guaranteed to be valid for the lifetime of the search + // thread. const self: *Surface = @ptrCast(@alignCast(ud.?)); - _ = self; - _ = event; + self.searchCallback_(event) catch |err| { + log.warn("error in search callback err={}", .{err}); + }; +} + +fn searchCallback_( + self: *Surface, + event: terminal.search.Thread.Event, +) !void { + switch (event) { + .viewport_matches => |matches_unowned| { + var arena: ArenaAllocator = .init(self.alloc); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + const matches = try alloc.dupe(terminal.highlight.Flattened, matches_unowned); + for (matches) |*m| m.* = try m.clone(alloc); + + _ = self.renderer_thread.mailbox.push( + .{ .search_viewport_matches = .{ + .arena = arena, + .matches = matches, + } }, + .forever, + ); + try self.renderer_thread.wakeup.notify(); + }, + + // Unhandled, so far. + .total_matches, + .complete, + => {}, + } } /// Call this when modifiers change. This is safe to call even if modifiers diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 004cfd5fa..738dce61c 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -451,6 +451,14 @@ fn drainMailbox(self: *Thread) !void { self.startDrawTimer(); }, + .search_viewport_matches => |v| { + // Note we don't free the new value because we expect our + // allocators to match. + if (self.renderer.search_matches) |*m| m.arena.deinit(); + self.renderer.search_matches = v; + self.renderer.search_matches_dirty = true; + }, + .inspector => |v| self.flags.has_inspector = v, .macos_display_id => |v| { diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 025578c81..1a816e751 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -122,6 +122,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type { scrollbar: terminal.Scrollbar, scrollbar_dirty: bool, + /// The most recent viewport matches so that we can render search + /// matches in the visible frame. This is provided asynchronously + /// from the search thread so we have the dirty flag to also note + /// if we need to rebuild our cells to include search highlights. + /// + /// Note that the selections MAY BE INVALID (point to PageList nodes + /// that do not exist anymore). These must be validated prior to use. + search_matches: ?renderer.Message.SearchMatches, + search_matches_dirty: bool, + /// The current set of cells to render. This is rebuilt on every frame /// but we keep this around so that we don't reallocate. Each set of /// cells goes into a separate shader. @@ -672,6 +682,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .focused = true, .scrollbar = .zero, .scrollbar_dirty = false, + .search_matches = null, + .search_matches_dirty = false, // Render state .cells = .{}, @@ -744,7 +756,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { pub fn deinit(self: *Self) void { self.terminal_state.deinit(self.alloc); - + if (self.search_matches) |*m| m.arena.deinit(); self.swap_chain.deinit(); if (DisplayLink != void) { diff --git a/src/renderer/message.zig b/src/renderer/message.zig index b36a99d5c..8a319166b 100644 --- a/src/renderer/message.zig +++ b/src/renderer/message.zig @@ -1,6 +1,7 @@ const std = @import("std"); const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; const configpkg = @import("../config.zig"); const font = @import("../font/main.zig"); const renderer = @import("../renderer.zig"); @@ -10,7 +11,7 @@ const terminal = @import("../terminal/main.zig"); pub const Message = union(enum) { /// Purposely crash the renderer. This is used for testing and debugging. /// See the "crash" binding action. - crash: void, + crash, /// A change in state in the window focus that this renderer is /// rendering within. This is only sent when a change is detected so @@ -24,7 +25,7 @@ pub const Message = union(enum) { /// Reset the cursor blink by immediately showing the cursor then /// restarting the timer. - reset_cursor_blink: void, + reset_cursor_blink, /// Change the font grid. This can happen for any number of reasons /// including a font size change, family change, etc. @@ -52,12 +53,22 @@ pub const Message = union(enum) { impl: *renderer.Renderer.DerivedConfig, }, + /// Matches for the current viewport from the search thread. These happen + /// async so they may be off for a frame or two from the actually rendered + /// viewport. The renderer must handle this gracefully. + search_viewport_matches: SearchMatches, + /// Activate or deactivate the inspector. inspector: bool, /// The macOS display ID has changed for the window. macos_display_id: u32, + pub const SearchMatches = struct { + arena: ArenaAllocator, + matches: []const terminal.highlight.Flattened, + }; + /// Initialize a change_config message. pub fn initChangeConfig(alloc: Allocator, config: *const configpkg.Config) !Message { const thread_ptr = try alloc.create(renderer.Thread.DerivedConfig); From dd9ed531ad16a1fe9d06e088c1f26dbe922ed321 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Nov 2025 12:26:59 -0800 Subject: [PATCH 106/209] render viewport matches --- src/renderer/generic.zig | 30 ++++++++++++++++++- src/terminal/render.zig | 63 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 1a816e751..691831e8a 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1191,6 +1191,23 @@ pub fn Renderer(comptime GraphicsAPI: type) type { log.warn("error searching for regex links err={}", .{err}); }; + // Clear our highlight state and update. + if (self.search_matches_dirty or self.terminal_state.dirty != .false) { + for (self.terminal_state.row_data.items(.highlights)) |*highlights| { + highlights.clearRetainingCapacity(); + } + + if (self.search_matches) |m| { + self.terminal_state.updateHighlightsFlattened( + self.alloc, + m.matches, + ) catch |err| { + // Not a critical error, we just won't show highlights. + log.warn("error updating search highlights err={}", .{err}); + }; + } + } + // Build our GPU cells try self.rebuildCells( critical.preedit, @@ -2366,6 +2383,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const row_cells = row_data.items(.cells); const row_dirty = row_data.items(.dirty); const row_selection = row_data.items(.selection); + const row_highlights = row_data.items(.highlights); // If our cell contents buffer is shorter than the screen viewport, // we render the rows that fit, starting from the bottom. If instead @@ -2381,7 +2399,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { row_cells[0..row_len], row_dirty[0..row_len], row_selection[0..row_len], - ) |y_usize, row, *cells, *dirty, selection| { + row_highlights[0..row_len], + ) |y_usize, row, *cells, *dirty, selection, highlights| { const y: terminal.size.CellCountInt = @intCast(y_usize); if (!rebuild) { @@ -2526,6 +2545,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // True if this cell is selected const selected: bool = selected: { + // If we're highlighted, then we're selected. In the + // future we want to use a different style for this + // but this to get started. + for (highlights.items) |hl| { + if (x >= hl[0] and x <= hl[1]) { + break :selected true; + } + } + const sel = selection orelse break :selected false; const x_compare = if (wide == .spacer_tail) x -| 1 diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 86b299d72..49fc5af71 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -5,6 +5,7 @@ const ArenaAllocator = std.heap.ArenaAllocator; const fastmem = @import("../fastmem.zig"); const color = @import("color.zig"); const cursor = @import("cursor.zig"); +const highlight = @import("highlight.zig"); const point = @import("point.zig"); const size = @import("size.zig"); const page = @import("page.zig"); @@ -191,6 +192,10 @@ pub const RenderState = struct { /// The x range of the selection within this row. selection: ?[2]size.CellCountInt, + + /// The x ranges of highlights within this row. Highlights are + /// applied after the update by calling `updateHighlights`. + highlights: std.ArrayList([2]size.CellCountInt), }; pub const Cell = struct { @@ -348,6 +353,7 @@ pub const RenderState = struct { .cells = .empty, .dirty = true, .selection = null, + .highlights = .empty, }); } } else { @@ -630,6 +636,63 @@ pub const RenderState = struct { s.dirty = .{}; } + /// Update the highlights in the render state from the given flattened + /// highlights. Because this uses flattened highlights, it does not require + /// reading from the terminal state so it should be done outside of + /// any critical sections. + /// + /// This will not clear any previous highlights, so the caller must + /// manually clear them if desired. + pub fn updateHighlightsFlattened( + self: *RenderState, + alloc: Allocator, + hls: []const highlight.Flattened, + ) Allocator.Error!void { + // Fast path, we have no highlights! + if (hls.len == 0) return; + + // This is, admittedly, horrendous. This is some low hanging fruit + // to optimize. In my defense, screens are usually small, the number + // of highlights is usually small, and this only happens on the + // viewport outside of a locked area. Still, I'd love to see this + // improved someday. + const row_data = self.row_data.slice(); + const row_arenas = row_data.items(.arena); + const row_pins = row_data.items(.pin); + const row_highlights_slice = row_data.items(.highlights); + for ( + row_arenas, + row_pins, + row_highlights_slice, + ) |*row_arena, row_pin, *row_highlights| { + for (hls) |hl| { + const chunks_slice = hl.chunks.slice(); + const nodes = chunks_slice.items(.node); + const starts = chunks_slice.items(.start); + const ends = chunks_slice.items(.end); + for (0.., nodes) |i, node| { + // If this node doesn't match or we're not within + // the row range, skip it. + if (node != row_pin.node or + row_pin.y < starts[i] or + row_pin.y >= ends[i]) continue; + + // We're a match! + var arena = row_arena.promote(alloc); + defer row_arena.* = arena.state; + const arena_alloc = arena.allocator(); + try row_highlights.append( + arena_alloc, + .{ + if (i == 0) hl.top_x else 0, + if (i == nodes.len - 1) hl.bot_x else self.cols - 1, + }, + ); + } + } + } + } + pub const StringMap = std.ArrayListUnmanaged(point.Coordinate); /// Convert the current render state contents to a UTF-8 encoded From d0e3a79a74ac088be0a2db658abd8d634f043cb0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Nov 2025 12:35:57 -0800 Subject: [PATCH 107/209] reset search on needle change or quit --- src/Surface.zig | 14 ++++++++++++++ src/terminal/search/Thread.zig | 25 ++++++++++++++++++++++++- src/terminal/search/viewport.zig | 7 ++++--- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index cfc0b14aa..989495309 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1342,6 +1342,8 @@ fn searchCallback_( self: *Surface, event: terminal.search.Thread.Event, ) !void { + // NOTE: This runs on the search thread. + switch (event) { .viewport_matches => |matches_unowned| { var arena: ArenaAllocator = .init(self.alloc); @@ -1361,6 +1363,18 @@ fn searchCallback_( try self.renderer_thread.wakeup.notify(); }, + // When we quit, tell our renderer to reset any search state. + .quit => { + _ = self.renderer_thread.mailbox.push( + .{ .search_viewport_matches = .{ + .arena = .init(self.alloc), + .matches = &.{}, + } }, + .forever, + ); + try self.renderer_thread.wakeup.notify(); + }, + // Unhandled, so far. .total_matches, .complete, diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 7ca9df0b7..2c5607809 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -163,7 +163,14 @@ fn threadMain_(self: *Thread) !void { // Run log.debug("starting search thread", .{}); - defer log.debug("starting search thread shutdown", .{}); + defer { + log.debug("starting search thread shutdown", .{}); + + // Send the quit message + if (self.opts.event_cb) |cb| { + cb(.quit, self.opts.event_userdata); + } + } // Unlike some of our other threads, we interleave search work // with our xev loop so that we can try to make forward search progress @@ -247,6 +254,18 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void { if (self.search) |*s| { s.deinit(); self.search = null; + + // When the search changes then we need to emit that it stopped. + if (self.opts.event_cb) |cb| { + cb( + .{ .total_matches = 0 }, + self.opts.event_userdata, + ); + cb( + .{ .viewport_matches = &.{} }, + self.opts.event_userdata, + ); + } } // No needle means stop the search. @@ -381,6 +400,9 @@ pub const Message = union(enum) { /// Events that can be emitted from the search thread. The caller /// chooses to handle these as they see fit. pub const Event = union(enum) { + /// Search is quitting. The search thread is exiting. + quit, + /// Search is complete for the given needle on all screens. complete, @@ -668,6 +690,7 @@ test { fn callback(event: Event, userdata: ?*anyopaque) void { const ud: *Self = @ptrCast(@alignCast(userdata.?)); switch (event) { + .quit => {}, .complete => ud.reset.set(), .total_matches => |v| ud.total = v, .viewport_matches => |v| { diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index 6a266f47a..55eedb724 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -326,15 +326,16 @@ test "clear screen and search dirty tracking" { try testing.expect(try search.update(&t.screens.active.pages)); { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 1, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 1, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } try testing.expect(search.next() == null); } From 06981175afec87c23c6aec569ccb0f2b9770343c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Nov 2025 13:36:10 -0800 Subject: [PATCH 108/209] renderer: reset search dirty state after processing --- src/renderer/generic.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 691831e8a..b1a0151a4 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1193,6 +1193,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Clear our highlight state and update. if (self.search_matches_dirty or self.terminal_state.dirty != .false) { + self.search_matches_dirty = false; + for (self.terminal_state.row_data.items(.highlights)) |*highlights| { highlights.clearRetainingCapacity(); } From a4e40c75671400e11eaaeecb83a0c01bbeb818ed Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Nov 2025 13:59:50 -0800 Subject: [PATCH 109/209] set proper dirty state to redo viewport search --- src/renderer/generic.zig | 6 ++++++ src/terminal/render.zig | 15 ++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index b1a0151a4..fb82efd8d 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1126,6 +1126,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Update our terminal state try self.terminal_state.update(self.alloc, state.terminal); + // If our terminal state is dirty at all we need to redo + // the viewport search. + if (self.terminal_state.dirty != .false) { + state.terminal.flags.search_viewport_dirty = true; + } + // Get our scrollbar out of the terminal. We synchronize // the scrollbar read with frame data updates because this // naturally limits the number of calls to this method (it diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 49fc5af71..8f4da12eb 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -656,15 +656,22 @@ pub const RenderState = struct { // of highlights is usually small, and this only happens on the // viewport outside of a locked area. Still, I'd love to see this // improved someday. + + // We need to track whether any row had a match so we can mark + // the dirty state. + var any_dirty: bool = false; + const row_data = self.row_data.slice(); const row_arenas = row_data.items(.arena); + const row_dirties = row_data.items(.dirty); const row_pins = row_data.items(.pin); const row_highlights_slice = row_data.items(.highlights); for ( row_arenas, row_pins, row_highlights_slice, - ) |*row_arena, row_pin, *row_highlights| { + row_dirties, + ) |*row_arena, row_pin, *row_highlights, *dirty| { for (hls) |hl| { const chunks_slice = hl.chunks.slice(); const nodes = chunks_slice.items(.node); @@ -688,9 +695,15 @@ pub const RenderState = struct { if (i == nodes.len - 1) hl.bot_x else self.cols - 1, }, ); + + dirty.* = true; + any_dirty = true; } } } + + // Mark our dirty state. + if (any_dirty and self.dirty == .false) self.dirty = .partial; } pub const StringMap = std.ArrayListUnmanaged(point.Coordinate); From de16e4a92b2bffb4c4cb277742552fe6b5044c75 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Nov 2025 20:02:06 -0800 Subject: [PATCH 110/209] config: add selection-foreground/background --- src/config/Config.zig | 14 +++++++ src/renderer/generic.zig | 88 +++++++++++++++++++++++----------------- 2 files changed, 64 insertions(+), 38 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 6355b6c26..89254b93f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -978,6 +978,20 @@ palette: Palette = .{}, /// Available since: 1.1.0 @"split-divider-color": ?Color = null, +/// The foreground and background color for search matches. This only applies +/// to non-focused search matches, also known as candidate matches. +/// +/// Valid values: +/// +/// - Hex (`#RRGGBB` or `RRGGBB`) +/// - Named X11 color +/// - "cell-foreground" to match the cell foreground color +/// - "cell-background" to match the cell background color +/// +/// The default value is +@"search-foreground": TerminalColor = .{ .color = .{ .r = 0, .g = 0, .b = 0 } }, +@"search-background": TerminalColor = .{ .color = .{ .r = 0xFF, .g = 0xE0, .b = 0x82 } }, + /// The command to run, usually a shell. If this is not an absolute path, it'll /// be looked up in the `PATH`. If this is not set, a default will be looked up /// from your system. The rules for the default lookup are: diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index fb82efd8d..7701a5418 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -537,6 +537,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { foreground: terminal.color.RGB, selection_background: ?configpkg.Config.TerminalColor, selection_foreground: ?configpkg.Config.TerminalColor, + search_background: configpkg.Config.TerminalColor, + search_foreground: configpkg.Config.TerminalColor, bold_color: ?configpkg.BoldColor, faint_opacity: u8, min_contrast: f32, @@ -608,6 +610,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .selection_background = config.@"selection-background", .selection_foreground = config.@"selection-foreground", + .search_background = config.@"search-background", + .search_foreground = config.@"search-foreground", .custom_shaders = custom_shaders, .bg_image = bg_image, @@ -2552,24 +2556,30 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .{}; // True if this cell is selected - const selected: bool = selected: { + const selected: enum { + false, + selection, + search, + } = selected: { // If we're highlighted, then we're selected. In the // future we want to use a different style for this // but this to get started. for (highlights.items) |hl| { if (x >= hl[0] and x <= hl[1]) { - break :selected true; + break :selected .search; } } - const sel = selection orelse break :selected false; + const sel = selection orelse break :selected .false; const x_compare = if (wide == .spacer_tail) x -| 1 else x; - break :selected x_compare >= sel[0] and - x_compare <= sel[1]; + if (x_compare >= sel[0] and + x_compare <= sel[1]) break :selected .selection; + + break :selected .false; }; // The `_style` suffixed values are the colors based on @@ -2586,25 +2596,26 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }); // The final background color for the cell. - const bg = bg: { - if (selected) { - // If we have an explicit selection background color - // specified int he config, use that - if (self.config.selection_background) |v| { - break :bg switch (v) { - .color => |color| color.toTerminalRGB(), - .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, - .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, - }; - } + const bg = switch (selected) { + // If we have an explicit selection background color + // specified in the config, use that. + // + // If no configuration, then our selection background + // is our foreground color. + .selection => if (self.config.selection_background) |v| switch (v) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, + } else state.colors.foreground, - // If no configuration, then our selection background - // is our foreground color. - break :bg state.colors.foreground; - } + .search => switch (self.config.search_background) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, + }, // Not selected - break :bg if (style.flags.inverse != isCovering(cell.codepoint())) + .false => if (style.flags.inverse != isCovering(cell.codepoint())) // Two cases cause us to invert (use the fg color as the bg) // - The "inverse" style flag. // - A "covering" glyph; we use fg for bg in that @@ -2616,7 +2627,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { fg_style else // Otherwise they cancel out. - bg_style; + bg_style, }; const fg = fg: { @@ -2628,23 +2639,24 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // - Cell is selected, inverted, and set to cell-foreground // - Cell is selected, not inverted, and set to cell-background // - Cell is inverted and not selected - if (selected) { - // Use the selection foreground if set - if (self.config.selection_foreground) |v| { - break :fg switch (v) { - .color => |color| color.toTerminalRGB(), - .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, - .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, - }; - } + break :fg switch (selected) { + .selection => if (self.config.selection_foreground) |v| switch (v) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, + } else state.colors.background, - break :fg state.colors.background; - } + .search => switch (self.config.search_foreground) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, + }, - break :fg if (style.flags.inverse) - final_bg - else - fg_style; + .false => if (style.flags.inverse) + final_bg + else + fg_style, + }; }; // Foreground alpha for this cell. @@ -2662,7 +2674,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const default: u8 = 255; // Cells that are selected should be fully opaque. - if (selected) break :bg_alpha default; + if (selected != .false) break :bg_alpha default; // Cells that are reversed should be fully opaque. if (style.flags.inverse) break :bg_alpha default; From bb21c3d6b3efc8543f1f6ca155e1c8506544f4c6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Nov 2025 20:25:25 -0800 Subject: [PATCH 111/209] search: case-insesitive (ascii) search --- src/terminal/search/sliding_window.zig | 51 ++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index c1428e35c..ff0fa0277 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -174,7 +174,7 @@ pub const SlidingWindow = struct { }; // Search the first slice for the needle. - if (std.mem.indexOf(u8, slices[0], self.needle)) |idx| { + if (std.ascii.indexOfIgnoreCase(slices[0], self.needle)) |idx| { return self.highlight( idx, self.needle.len, @@ -200,8 +200,7 @@ pub const SlidingWindow = struct { @memcpy(self.overlap_buf[prefix.len..overlap_len], suffix); // Search the overlap - const idx = std.mem.indexOf( - u8, + const idx = std.ascii.indexOfIgnoreCase( self.overlap_buf[0..overlap_len], self.needle, ) orelse break :overlap; @@ -215,7 +214,7 @@ pub const SlidingWindow = struct { } // Search the last slice for the needle. - if (std.mem.indexOf(u8, slices[1], self.needle)) |idx| { + if (std.ascii.indexOfIgnoreCase(slices[1], self.needle)) |idx| { return self.highlight( slices[0].len + idx, self.needle.len, @@ -660,6 +659,50 @@ test "SlidingWindow single append" { try testing.expect(w.next() == null); } +test "SlidingWindow single append case insensitive ASCII" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "Boo!"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello. boo! hello. boo!"); + + // We want to test single-page cases. + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + + // We should be able to find two matches. + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 10, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end)); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 22, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end)); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} test "SlidingWindow single append no match" { const testing = std.testing; const alloc = testing.allocator; From d31be89b169736d00dccc2f19ca6fde5c1aee7a1 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Mon, 24 Nov 2025 20:53:23 -0800 Subject: [PATCH 112/209] fix(renderer): load linearized fg color for cursor cell --- src/renderer/shaders/shaders.metal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/shaders/shaders.metal b/src/renderer/shaders/shaders.metal index 4797f89e4..4e02b6336 100644 --- a/src/renderer/shaders/shaders.metal +++ b/src/renderer/shaders/shaders.metal @@ -668,7 +668,7 @@ vertex CellTextVertexOut cell_text_vertex( out.color = load_color( uniforms.cursor_color, uniforms.use_display_p3, - false + true ); } From c92a00332527d8f318e0b8cd5c588a693a30a877 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Nov 2025 21:18:48 -0800 Subject: [PATCH 113/209] pkg/{highway,simdutf}: disable ubsan This causes linker issues for some libghostty users. I don't know why we never saw these issues with Ghostty release builds, but generally speaking I think its fine to do this for 3rd party code unless we've witnessed an issue. And these deps have been stable for a long, long time. --- pkg/highway/build.zig | 4 ++++ pkg/simdutf/build.zig | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/pkg/highway/build.zig b/pkg/highway/build.zig index 4c75de49e..fd93675e6 100644 --- a/pkg/highway/build.zig +++ b/pkg/highway/build.zig @@ -67,6 +67,10 @@ pub fn build(b: *std.Build) !void { "-fno-cxx-exceptions", "-fno-slp-vectorize", "-fno-vectorize", + + // Fixes linker issues for release builds missing ubsanitizer symbols + "-fno-sanitize=undefined", + "-fno-sanitize-trap=undefined", }); if (target.result.os.tag != .windows) { try flags.appendSlice(b.allocator, &.{ diff --git a/pkg/simdutf/build.zig b/pkg/simdutf/build.zig index f2ddfeba4..0d827c1cc 100644 --- a/pkg/simdutf/build.zig +++ b/pkg/simdutf/build.zig @@ -24,7 +24,13 @@ pub fn build(b: *std.Build) !void { defer flags.deinit(b.allocator); // Zig 0.13 bug: https://github.com/ziglang/zig/issues/20414 // (See root Ghostty build.zig on why we do this) - try flags.appendSlice(b.allocator, &.{"-DSIMDUTF_IMPLEMENTATION_ICELAKE=0"}); + try flags.appendSlice(b.allocator, &.{ + "-DSIMDUTF_IMPLEMENTATION_ICELAKE=0", + + // Fixes linker issues for release builds missing ubsanitizer symbols + "-fno-sanitize=undefined", + "-fno-sanitize-trap=undefined", + }); lib.addCSourceFiles(.{ .flags = flags.items, From 2a627a466518086c5a7da9dbaacd67e3bdd5f29b Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Tue, 25 Nov 2025 11:15:19 +0100 Subject: [PATCH 114/209] macOS: fix the animation of showing&hiding command palette --- .../Command Palette/CommandPalette.swift | 39 ++++++++++++++----- .../TerminalCommandPalette.swift | 10 ++--- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 537137fe6..79c3ca756 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -44,6 +44,7 @@ struct CommandPaletteView: View { @State private var query = "" @State private var selectedIndex: UInt? @State private var hoveredOptionID: UUID? + @FocusState private var isTextFieldFocused: Bool // The options that we should show, taking into account any filtering from // the query. @@ -72,7 +73,7 @@ struct CommandPaletteView: View { } VStack(alignment: .leading, spacing: 0) { - CommandPaletteQuery(query: $query) { event in + CommandPaletteQuery(query: $query, isTextFieldFocused: _isTextFieldFocused) { event in switch (event) { case .exit: isPresented = false @@ -144,6 +145,28 @@ struct CommandPaletteView: View { .shadow(radius: 32, x: 0, y: 12) .padding() .environment(\.colorScheme, scheme) + .onChange(of: isPresented) { newValue in + // Reset focus when quickly showing and hiding. + // macOS will destroy this view after a while, + // so task/onAppear will not be called again. + // If you toggle it rather quickly, we reset + // it here when dismissing. + isTextFieldFocused = newValue + if !isPresented { + // This is optional, since most of the time + // there will be a delay before the next use. + // To keep behavior the same as before, we reset it. + query = "" + } + } + .task { + // Grab focus on the first appearance. + // This happens right after onAppear, + // so we don’t need to dispatch it again. + // Fixes: https://github.com/ghostty-org/ghostty/issues/8497 + // Also fixes initial focus while animating. + isTextFieldFocused = isPresented + } } } @@ -153,6 +176,12 @@ fileprivate struct CommandPaletteQuery: View { var onEvent: ((KeyboardEvent) -> Void)? = nil @FocusState private var isTextFieldFocused: Bool + init(query: Binding, isTextFieldFocused: FocusState, onEvent: ((KeyboardEvent) -> Void)? = nil) { + _query = query + self.onEvent = onEvent + _isTextFieldFocused = isTextFieldFocused + } + enum KeyboardEvent { case exit case submit @@ -185,14 +214,6 @@ fileprivate struct CommandPaletteQuery: View { .frame(height: 48) .textFieldStyle(.plain) .focused($isTextFieldFocused) - .onAppear { - // We want to grab focus on appearance. We have to do this after a tick - // on macOS Tahoe otherwise this doesn't work. See: - // https://github.com/ghostty-org/ghostty/issues/8497 - DispatchQueue.main.async { - isTextFieldFocused = true - } - } .onChange(of: isTextFieldFocused) { focused in if !focused { onEvent?(.exit) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 673f5dd78..96ff3d0c1 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -90,19 +90,19 @@ struct TerminalCommandPaletteView: View { backgroundColor: ghosttyConfig.backgroundColor, options: commandOptions ) - .transition( - .move(edge: .top) - .combined(with: .opacity) - .animation(.spring(response: 0.4, dampingFraction: 0.8)) - ) // Spring animation .zIndex(1) // Ensure it's on top Spacer() } .frame(width: geometry.size.width, height: geometry.size.height, alignment: .top) } + .transition( + .move(edge: .top) + .combined(with: .opacity) + ) } } + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: isPresented) .onChange(of: isPresented) { newValue in // When the command palette disappears we need to send focus back to the // surface view we were overlaid on top of. There's probably a better way From 807febcb5edb9fe7f6dbd9a6430c9034ec22e592 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Tue, 25 Nov 2025 09:07:21 -0500 Subject: [PATCH 115/209] benchmarks: align read_buf to cache line --- src/benchmark/CodepointWidth.zig | 6 +++--- src/benchmark/GraphemeBreak.zig | 4 ++-- src/benchmark/IsSymbol.zig | 4 ++-- src/benchmark/ScreenClone.zig | 2 +- src/benchmark/TerminalParser.zig | 2 +- src/benchmark/TerminalStream.zig | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/benchmark/CodepointWidth.zig b/src/benchmark/CodepointWidth.zig index 552df8d1f..effabb036 100644 --- a/src/benchmark/CodepointWidth.zig +++ b/src/benchmark/CodepointWidth.zig @@ -107,7 +107,7 @@ fn stepWcwidth(ptr: *anyopaque) Benchmark.Error!void { const self: *CodepointWidth = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; @@ -134,7 +134,7 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void { const self: *CodepointWidth = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; @@ -166,7 +166,7 @@ fn stepSimd(ptr: *anyopaque) Benchmark.Error!void { const self: *CodepointWidth = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; diff --git a/src/benchmark/GraphemeBreak.zig b/src/benchmark/GraphemeBreak.zig index a1b3380f0..328d63a75 100644 --- a/src/benchmark/GraphemeBreak.zig +++ b/src/benchmark/GraphemeBreak.zig @@ -90,7 +90,7 @@ fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { const self: *GraphemeBreak = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; @@ -113,7 +113,7 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void { const self: *GraphemeBreak = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; diff --git a/src/benchmark/IsSymbol.zig b/src/benchmark/IsSymbol.zig index c4667b333..5ba2da907 100644 --- a/src/benchmark/IsSymbol.zig +++ b/src/benchmark/IsSymbol.zig @@ -90,7 +90,7 @@ fn stepUucode(ptr: *anyopaque) Benchmark.Error!void { const self: *IsSymbol = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; @@ -117,7 +117,7 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void { const self: *IsSymbol = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; diff --git a/src/benchmark/ScreenClone.zig b/src/benchmark/ScreenClone.zig index 7225aff4e..380379bc3 100644 --- a/src/benchmark/ScreenClone.zig +++ b/src/benchmark/ScreenClone.zig @@ -109,7 +109,7 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { var stream = self.terminal.vtStream(); defer stream.deinit(); - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = data_f.reader(&read_buf); const r = &f_reader.interface; diff --git a/src/benchmark/TerminalParser.zig b/src/benchmark/TerminalParser.zig index f13b44552..e00081763 100644 --- a/src/benchmark/TerminalParser.zig +++ b/src/benchmark/TerminalParser.zig @@ -75,7 +75,7 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { // the benchmark results and... I know writing this that we // aren't currently IO bound. const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; diff --git a/src/benchmark/TerminalStream.zig b/src/benchmark/TerminalStream.zig index 0a993c42b..7cf28217f 100644 --- a/src/benchmark/TerminalStream.zig +++ b/src/benchmark/TerminalStream.zig @@ -114,7 +114,7 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { // aren't currently IO bound. const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); const r = &f_reader.interface; From 08f57ab6d6a53108e0db5bccd10db196b95bfd43 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Nov 2025 21:12:53 -0800 Subject: [PATCH 116/209] search: prune invalid history entries on feed --- src/terminal/search/screen.zig | 62 ++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 071ccd090..a0697170d 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -90,6 +90,11 @@ pub const ScreenSearch = struct { pub fn needsFeed(self: State) bool { return switch (self) { .history_feed => true, + + // Not obvious but complete search states will prune + // stale history results on feed. + .complete => true, + else => false, }; } @@ -216,6 +221,9 @@ pub const ScreenSearch = struct { /// Feed more data to the searcher so it can continue searching. This /// accesses the screen state, so the caller must hold the necessary locks. + /// + /// Feed on a complete screen search will perform some cleanup of + /// potentially stale history results (pruned) and reclaim some memory. pub fn feed(self: *ScreenSearch) Allocator.Error!void { const history: *PageListSearch = if (self.history) |*h| &h.searcher else { // No history to feed, search is complete. @@ -228,6 +236,11 @@ pub const ScreenSearch = struct { if (!try history.feed()) { // No more data to feed, search is complete. self.state = .complete; + + // We use this opportunity to also clean up older history + // results that may be gone due to scrollback pruning, though. + self.pruneHistory(); + return; } @@ -246,6 +259,55 @@ pub const ScreenSearch = struct { } } + fn pruneHistory(self: *ScreenSearch) void { + const history: *PageListSearch = if (self.history) |*h| &h.searcher else return; + + // Keep track of the last checked node to avoid redundant work. + var last_checked: ?*PageList.List.Node = null; + + // Go through our history results in reverse order to find + // the oldest matches first (since oldest nodes are pruned first). + for (0..self.history_results.items.len) |rev_i| { + const i = self.history_results.items.len - 1 - rev_i; + const node = node: { + const hl = &self.history_results.items[i]; + break :node hl.chunks.items(.node)[0]; + }; + + // If this is the same node as what we last checked and + // found to prune, then continue until we find the first + // non-matching, non-pruned node so we can prune the older + // ones. + if (last_checked == node) continue; + last_checked = node; + + // Try to find this node in the PageList using a standard + // O(N) traversal. This isn't as bad as it seems because our + // oldest matches are likely to be near the start of the + // list and as soon as we find one we're done. + var it = history.list.pages.first; + while (it) |valid_node| : (it = valid_node.next) { + if (valid_node != node) continue; + + // This is a valid node. If we're not at rev_i 0 then + // it means we have some data to prune! If we are + // at rev_i 0 then we can break out because there + // is nothing to prune. + if (rev_i == 0) return; + + // Prune the last rev_i items. + const alloc = self.allocator(); + for (self.history_results.items[i + 1 ..]) |*prune_hl| { + prune_hl.deinit(alloc); + } + self.history_results.shrinkAndFree(alloc, i); + + // Once we've pruned, future results can't be invalid. + return; + } + } + } + fn tickActive(self: *ScreenSearch) Allocator.Error!void { // For the active area, we consume the entire search in one go // because the active area is generally small. From 23479fe409c5ed7e958b1c279b5482226c2da97e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 08:59:21 -0800 Subject: [PATCH 117/209] search: select next search match --- src/terminal/highlight.zig | 11 +++ src/terminal/search/screen.zig | 175 ++++++++++++++++++++++++++++++++- 2 files changed, 185 insertions(+), 1 deletion(-) diff --git a/src/terminal/highlight.zig b/src/terminal/highlight.zig index 772d4d54b..99ef7ba86 100644 --- a/src/terminal/highlight.zig +++ b/src/terminal/highlight.zig @@ -32,6 +32,17 @@ const Screen = @import("Screen.zig"); pub const Untracked = struct { start: Pin, end: Pin, + + pub fn track( + self: *const Untracked, + screen: *Screen, + ) Allocator.Error!Tracked { + return try .init( + screen, + self.start, + self.end, + ); + } }; /// A tracked highlight is a highlight that stores its highlighted diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index a0697170d..d0007c141 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -3,7 +3,9 @@ const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; const point = @import("../point.zig"); -const FlattenedHighlight = @import("../highlight.zig").Flattened; +const highlight = @import("../highlight.zig"); +const FlattenedHighlight = highlight.Flattened; +const TrackedHighlight = highlight.Tracked; const PageList = @import("../PageList.zig"); const Pin = PageList.Pin; const Screen = @import("../Screen.zig"); @@ -41,6 +43,11 @@ pub const ScreenSearch = struct { /// Current state of the search, a state machine. state: State, + /// The currently selected match, if any. As the screen contents + /// change or get pruned, the screen search will do its best to keep + /// this accurate. + selected: ?SelectedMatch = null, + /// The results found so far. These are stored separately because history /// is mostly immutable once found, while active area results may /// change. This lets us easily reset the active area results for a @@ -48,6 +55,18 @@ pub const ScreenSearch = struct { history_results: std.ArrayList(FlattenedHighlight), active_results: std.ArrayList(FlattenedHighlight), + const SelectedMatch = struct { + /// Index from the end of the match list (0 = most recent match) + idx: usize, + + /// Tracked highlight so we can detect movement. + highlight: TrackedHighlight, + + pub fn deinit(self: *SelectedMatch, screen: *Screen) void { + self.highlight.deinit(screen); + } + }; + /// History search state. const HistorySearch = struct { /// The actual searcher state. @@ -126,6 +145,7 @@ pub const ScreenSearch = struct { const alloc = self.allocator(); self.active.deinit(); if (self.history) |*h| h.deinit(self.screen); + if (self.selected) |*m| m.deinit(self.screen); for (self.active_results.items) |*hl| hl.deinit(alloc); self.active_results.deinit(alloc); for (self.history_results.items) |*hl| hl.deinit(alloc); @@ -473,6 +493,100 @@ pub const ScreenSearch = struct { }, } } + + pub const Select = enum { + /// Next selection, in reverse order (newest to oldest) + next, + }; + + /// Return the selected match. + /// + /// This does not require read/write access to the underlying screen. + pub fn selectedMatch(self: *const ScreenSearch) ?FlattenedHighlight { + const sel = self.selected orelse return null; + const active_len = self.active_results.items.len; + if (sel.idx < active_len) { + return self.active_results.items[active_len - 1 - sel.idx]; + } + + const history_len = self.history_results.items.len; + if (sel.idx < active_len + history_len) { + return self.history_results.items[sel.idx - active_len]; + } + + return null; + } + + /// Select the next or previous search result. This requires read/write + /// access to the underlying screen, since we utilize tracked pins to + /// ensure our selection sticks with contents changing. + pub fn select(self: *ScreenSearch, to: Select) Allocator.Error!void { + switch (to) { + .next => try self.selectNext(), + } + } + + fn selectNext(self: *ScreenSearch) Allocator.Error!void { + // All selection requires valid pins so we prune history and + // reload our active area immediately. This ensures all search + // results point to valid nodes. + try self.reloadActive(); + self.pruneHistory(); + + // Get our previous match so we can change it. If we have no + // prior match, we have the easy task of getting the first. + var prev = if (self.selected) |*m| m else { + // Get our highlight + const hl: FlattenedHighlight = hl: { + if (self.active_results.items.len > 0) { + // Active is in forward order + const len = self.active_results.items.len; + break :hl self.active_results.items[len - 1]; + } else if (self.history_results.items.len > 0) { + // History is in reverse order + break :hl self.history_results.items[0]; + } else { + // No matches at all. Can't select anything. + return; + } + }; + + // Pin it so we can track any movement + const tracked = try hl.untracked().track(self.screen); + errdefer tracked.deinit(self.screen); + + // Our selection is index zero since we just started and + // we store our selection. + self.selected = .{ + .idx = 0, + .highlight = tracked, + }; + return; + }; + + const next_idx = prev.idx + 1; + const active_len = self.active_results.items.len; + const history_len = self.history_results.items.len; + if (next_idx >= active_len + history_len) { + // No more matches. We don't wrap or reset the match currently. + return; + } + const hl: FlattenedHighlight = if (next_idx < active_len) + self.active_results.items[active_len - 1 - next_idx] + else + self.history_results.items[next_idx - active_len]; + + // Pin it so we can track any movement + const tracked = try hl.untracked().track(self.screen); + errdefer tracked.deinit(self.screen); + + // Free our previous match and setup our new selection + prev.deinit(self.screen); + self.selected = .{ + .idx = next_idx, + .highlight = tracked, + }; + } }; test "simple search" { @@ -685,3 +799,62 @@ test "active change contents" { } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } + +test "select next" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + + // Initially no selection + try testing.expect(search.selectedMatch() == null); + + // Select our next match (first) + try search.searchAll(); + try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Next match + try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Next match (no wrap) + try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } +} From c38e098c4ce7cb31a82ad974f8d6e5509cac7cb0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 09:20:28 -0800 Subject: [PATCH 118/209] search: fixup selected search when reloading active area --- src/terminal/highlight.zig | 11 ++ src/terminal/search/screen.zig | 227 +++++++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+) diff --git a/src/terminal/highlight.zig b/src/terminal/highlight.zig index 99ef7ba86..c236a4831 100644 --- a/src/terminal/highlight.zig +++ b/src/terminal/highlight.zig @@ -155,8 +155,19 @@ pub const Flattened = struct { }; } + pub fn startPin(self: Flattened) Pin { + const slice = self.chunks.slice(); + return .{ + .node = slice.items(.node)[0], + .x = self.top_x, + .y = slice.items(.start)[0], + }; + } + /// Convert to an Untracked highlight. pub fn untracked(self: Flattened) Untracked { + // Note: we don't use startPin/endPin here because it is slightly + // faster to reuse the slices. const slice = self.chunks.slice(); const nodes = slice.items(.node); const starts = slice.items(.start); diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index d0007c141..6c8661915 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -15,6 +15,8 @@ const ActiveSearch = @import("active.zig").ActiveSearch; const PageListSearch = @import("pagelist.zig").PageListSearch; const SlidingWindow = @import("sliding_window.zig").SlidingWindow; +const log = std.log.scoped(.search_screen); + /// Searches for a needle within a Screen, handling active area updates, /// pages being pruned from the screen (e.g. scrollback limits), and more. /// @@ -366,6 +368,10 @@ pub const ScreenSearch = struct { var hl_cloned = try hl.clone(alloc); errdefer hl_cloned.deinit(alloc); try self.history_results.append(alloc, hl_cloned); + + // Since history only appends to our results in reverse order, + // we don't need to update any selected match state. The index + // and prior results are unaffected. } // We need to be fed more data. @@ -380,6 +386,23 @@ pub const ScreenSearch = struct { /// /// The caller must hold the necessary locks to access the screen state. pub fn reloadActive(self: *ScreenSearch) Allocator.Error!void { + // If our selection pin became garbage it means we scrolled off + // the end. Clear our selection and on exit of this function, + // try to select the last match. + const select_prev: bool = select_prev: { + const m = if (self.selected) |*m| m else break :select_prev false; + if (!m.highlight.start.garbage and + !m.highlight.end.garbage) break :select_prev false; + + m.deinit(self.screen); + self.selected = null; + break :select_prev true; + }; + defer if (select_prev) self.select(.next) catch |err| { + // TODO: Change the above next to prev + log.info("reload failed to reset search selection err={}", .{err}); + }; + const alloc = self.allocator(); const list: *PageList = &self.screen.pages; if (try self.active.update(list)) |history_node| history: { @@ -474,8 +497,42 @@ pub const ScreenSearch = struct { try results.appendSlice(alloc, self.history_results.items); self.history_results.deinit(alloc); self.history_results = results; + + // If our prior selection was in the history area, update + // the offset. + if (self.selected) |*m| selected: { + const active_len = self.active_results.items.len; + if (m.idx < active_len) break :selected; + m.idx += results.items.len; + + // Moving the idx should not change our targeted result + // since the history is immutable. + if (comptime std.debug.runtime_safety) { + const hl = self.history_results.items[m.idx - active_len]; + assert(m.highlight.start.eql(hl.startPin())); + } + } } + // Figure out if we need to fixup our selection later because + // it was in the active area. + const old_active_len = self.active_results.items.len; + const old_selection_idx: ?usize = if (self.selected) |m| m.idx else null; + errdefer if (old_selection_idx != null and + old_selection_idx.? < old_active_len) + { + // This is the error scenario. If something fails below, + // our active area is probably gone, so we just go back + // to the first result because our selection can't be trusted. + if (self.selected) |*m| { + m.deinit(self.screen); + self.selected = null; + self.select(.next) catch |err| { + log.info("reload failed to reset search selection err={}", .{err}); + }; + } + }; + // Reset our active search results and search again. for (self.active_results.items) |*hl| hl.deinit(alloc); self.active_results.clearRetainingCapacity(); @@ -492,6 +549,40 @@ pub const ScreenSearch = struct { try self.tickActive(); }, } + + // Active area search was successful. Now we have to fixup our + // selection if we had one. + fixup: { + const old_idx = old_selection_idx orelse break :fixup; + const m = if (self.selected) |*m| m else break :fixup; + + // If our old selection wasn't in the active area, then we + // need to fix up our offsets. + if (old_idx >= old_active_len) { + m.idx -= old_active_len; + m.idx += self.active_results.items.len; + break :fixup; + } + + // We search for the matching highlight in the new active results. + for (0.., self.active_results.items) |i, hl| { + const untracked = hl.untracked(); + if (m.highlight.start.eql(untracked.start) and + m.highlight.end.eql(untracked.end)) + { + // Found it! Update our index. + m.idx = self.active_results.items.len - 1 - i; + break :fixup; + } + } + + // No match, just go back to the first match. + m.deinit(self.screen); + self.selected = null; + self.select(.next) catch |err| { + log.info("reload failed to reset search selection err={}", .{err}); + }; + } } pub const Select = enum { @@ -858,3 +949,139 @@ test "select next" { } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } + +test "select in active changes contents completely" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + try search.select(.next); + try search.select(.next); + { + // Initial selection is the first fizz + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Erase the screen, move our cursor to the top, and change contents. + try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home + try s.nextSlice("Fuzz\r\nFizz\r\nHello!"); + + try search.reloadActive(); + { + // Our selection should move to the first + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Erase the screen, redraw with same contents. + try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home + try s.nextSlice("Fuzz\r\nFizz\r\nFizz"); + + try search.reloadActive(); + { + // Our selection should not move to the first + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } +} + +test "select into history" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("Fizz\r\n"); + while (list.totalPages() < 3) try s.nextSlice("\r\n"); + for (0..list.rows) |_| try s.nextSlice("\r\n"); + try s.nextSlice("hello."); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + + // Get all matches + try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Erase the screen, redraw with same contents. + try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home + try s.nextSlice("yo yo"); + + try search.reloadActive(); + { + // Our selection should not move since the history is still active. + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Create some new history by adding more lines. + try s.nextSlice("\r\nfizz\r\nfizz\r\nfizz"); // Clear screen and move home + try search.reloadActive(); + { + // Our selection should not move since the history is still not + // pruned. + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } +} From a2a771bb6f9da739d9b867a8c06bf253f7ff1584 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 09:39:04 -0800 Subject: [PATCH 119/209] search: previous match --- src/terminal/search/screen.zig | 244 +++++++++++++++++++++++++++++++-- 1 file changed, 231 insertions(+), 13 deletions(-) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 6c8661915..4c632646c 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -398,8 +398,7 @@ pub const ScreenSearch = struct { self.selected = null; break :select_prev true; }; - defer if (select_prev) self.select(.next) catch |err| { - // TODO: Change the above next to prev + defer if (select_prev) self.select(.prev) catch |err| { log.info("reload failed to reset search selection err={}", .{err}); }; @@ -585,11 +584,6 @@ pub const ScreenSearch = struct { } } - pub const Select = enum { - /// Next selection, in reverse order (newest to oldest) - next, - }; - /// Return the selected match. /// /// This does not require read/write access to the underlying screen. @@ -608,22 +602,33 @@ pub const ScreenSearch = struct { return null; } + pub const Select = enum { + /// Next selection, in reverse order (newest to oldest), + /// non-wrapping. + next, + + /// Prev selection, in forward order (oldest to newest), + /// non-wrapping. + prev, + }; + /// Select the next or previous search result. This requires read/write /// access to the underlying screen, since we utilize tracked pins to /// ensure our selection sticks with contents changing. pub fn select(self: *ScreenSearch, to: Select) Allocator.Error!void { - switch (to) { - .next => try self.selectNext(), - } - } - - fn selectNext(self: *ScreenSearch) Allocator.Error!void { // All selection requires valid pins so we prune history and // reload our active area immediately. This ensures all search // results point to valid nodes. try self.reloadActive(); self.pruneHistory(); + switch (to) { + .next => try self.selectNext(), + .prev => try self.selectPrev(), + } + } + + fn selectNext(self: *ScreenSearch) Allocator.Error!void { // Get our previous match so we can change it. If we have no // prior match, we have the easy task of getting the first. var prev = if (self.selected) |*m| m else { @@ -678,6 +683,65 @@ pub const ScreenSearch = struct { .highlight = tracked, }; } + + fn selectPrev(self: *ScreenSearch) Allocator.Error!void { + // Get our previous match so we can change it. If we have no + // prior match, we have the easy task of getting the last. + var prev = if (self.selected) |*m| m else { + // Get our highlight (oldest match) + const hl: FlattenedHighlight = hl: { + if (self.history_results.items.len > 0) { + // History is in reverse order, so last item is oldest + const len = self.history_results.items.len; + break :hl self.history_results.items[len - 1]; + } else if (self.active_results.items.len > 0) { + // Active is in forward order, so first item is oldest + break :hl self.active_results.items[0]; + } else { + // No matches at all. Can't select anything. + return; + } + }; + + // Pin it so we can track any movement + const tracked = try hl.untracked().track(self.screen); + errdefer tracked.deinit(self.screen); + + // Our selection is the last index since we just started + // and we store our selection. + const active_len = self.active_results.items.len; + const history_len = self.history_results.items.len; + self.selected = .{ + .idx = active_len + history_len - 1, + .highlight = tracked, + }; + return; + }; + + // Can't go below zero + if (prev.idx == 0) { + // No more matches. We don't wrap or reset the match currently. + return; + } + + const next_idx = prev.idx - 1; + const active_len = self.active_results.items.len; + const hl: FlattenedHighlight = if (next_idx < active_len) + self.active_results.items[active_len - 1 - next_idx] + else + self.history_results.items[next_idx - active_len]; + + // Pin it so we can track any movement + const tracked = try hl.untracked().track(self.screen); + errdefer tracked.deinit(self.screen); + + // Free our previous match and setup our new selection + prev.deinit(self.screen); + self.selected = .{ + .idx = next_idx, + .highlight = tracked, + }; + } }; test "simple search" { @@ -1085,3 +1149,157 @@ test "select into history" { } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } + +test "select prev" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + + // Initially no selection + try testing.expect(search.selectedMatch() == null); + + // Select prev (oldest first) + try search.searchAll(); + try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Prev match (towards newest) + try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Prev match (no wrap, stays at newest) + try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } +} + +test "select prev then next" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + + // Select next (newest first) + try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + } + + // Select next (older) + try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + } + + // Select prev (back to newer) + try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + } +} + +test "select prev with history" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("Fizz\r\n"); + while (list.totalPages() < 3) try s.nextSlice("\r\n"); + for (0..list.rows) |_| try s.nextSlice("\r\n"); + try s.nextSlice("Fizz."); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + + // Select prev (oldest first, should be in history) + try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Select prev (towards newer, should move to active area) + try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); + } +} From 333dd08c974782dc426c16f0d3e8668887adbf16 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 10:17:54 -0800 Subject: [PATCH 120/209] search: thread dispatches selection notices, messages --- src/Surface.zig | 1 + src/terminal/highlight.zig | 4 + src/terminal/search/Thread.zig | 171 ++++++++++++++++++++++++--------- src/terminal/search/screen.zig | 2 +- 4 files changed, 129 insertions(+), 49 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 989495309..f0880d3c5 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1376,6 +1376,7 @@ fn searchCallback_( }, // Unhandled, so far. + .selected_match, .total_matches, .complete, => {}, diff --git a/src/terminal/highlight.zig b/src/terminal/highlight.zig index c236a4831..13c00b48e 100644 --- a/src/terminal/highlight.zig +++ b/src/terminal/highlight.zig @@ -43,6 +43,10 @@ pub const Untracked = struct { self.end, ); } + + pub fn eql(self: Untracked, other: Untracked) bool { + return self.start.eql(other.start) and self.end.eql(other.end); + } }; /// A tracked highlight is a highlight that stores its highlighted diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 2c5607809..2eea372e4 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -19,6 +19,7 @@ const internal_os = @import("../../os/main.zig"); const BlockingQueue = @import("../../datastruct/main.zig").BlockingQueue; const point = @import("../point.zig"); const FlattenedHighlight = @import("../highlight.zig").Flattened; +const UntrackedHighlight = @import("../highlight.zig").Untracked; const PageList = @import("../PageList.zig"); const Screen = @import("../Screen.zig"); const ScreenSet = @import("../ScreenSet.zig"); @@ -242,10 +243,23 @@ fn drainMailbox(self: *Thread) !void { log.debug("mailbox message={}", .{message}); switch (message) { .change_needle => |v| try self.changeNeedle(v), + .select => |v| try self.select(v), } } } +fn select(self: *Thread, sel: ScreenSearch.Select) !void { + const s = if (self.search) |*s| s else return; + const screen_search = s.screens.getPtr(s.last_screen.key) orelse return; + + self.opts.mutex.lock(); + defer self.opts.mutex.unlock(); + + // The selection will trigger a selection change notification + // if it did change. + try screen_search.select(sel); +} + /// Change the search term to the given value. fn changeNeedle(self: *Thread, needle: []const u8) !void { log.debug("changing search needle to '{s}'", .{needle}); @@ -395,6 +409,9 @@ pub const Message = union(enum) { /// will start a search. If an existing search term is given this will /// stop the prior search and start a new one. change_needle: []const u8, + + /// Select a search result. + select: ScreenSearch.Select, }; /// Events that can be emitted from the search thread. The caller @@ -409,9 +426,17 @@ pub const Event = union(enum) { /// Total matches on the current active screen have changed. total_matches: usize, + /// Selected match changed. + selected_match: ?SelectedMatch, + /// Matches in the viewport have changed. The memory is owned by the /// search thread and is only valid during the callback. viewport_matches: []const FlattenedHighlight, + + pub const SelectedMatch = struct { + idx: usize, + highlight: FlattenedHighlight, + }; }; /// Search state. @@ -422,11 +447,9 @@ const Search = struct { /// The searchers for all the screens. screens: std.EnumMap(ScreenSet.Key, ScreenSearch), - /// The last active screen - last_active_screen: ScreenSet.Key, - - /// The last total matches reported. - last_total: ?usize, + /// All state related to screen switches, collected so that when + /// we switch screens it makes everything related stale, too. + last_screen: ScreenState, /// True if we sent the complete notification yet. last_complete: bool, @@ -434,6 +457,22 @@ const Search = struct { /// The last viewport matches we found. stale_viewport_matches: bool, + const ScreenState = struct { + /// Last active screen key + key: ScreenSet.Key, + + /// Last notified total matches count + total: ?usize = null, + + /// Last notified selected match index + selected: ?SelectedMatch = null, + + const SelectedMatch = struct { + idx: usize, + highlight: UntrackedHighlight, + }; + }; + pub fn init( alloc: Allocator, needle: []const u8, @@ -448,8 +487,7 @@ const Search = struct { return .{ .viewport = vp, .screens = .init(.{}), - .last_active_screen = .primary, - .last_total = null, + .last_screen = .{ .key = .primary }, .last_complete = false, .stale_viewport_matches = true, }; @@ -528,9 +566,10 @@ const Search = struct { t: *Terminal, ) void { // Update our active screen - if (t.screens.active_key != self.last_active_screen) { - self.last_active_screen = t.screens.active_key; - self.last_total = null; // force notification + if (t.screens.active_key != self.last_screen.key) { + // The default values will force resets of a bunch of other + // state too to force recalculations and notifications. + self.last_screen = .{ .key = t.screens.active_key }; } // Reconcile our screens with the terminal screens. Remove @@ -621,13 +660,13 @@ const Search = struct { cb: EventCallback, ud: ?*anyopaque, ) void { - const screen_search = self.screens.get(self.last_active_screen) orelse return; + const screen_search = self.screens.get(self.last_screen.key) orelse return; // Check our total match data const total = screen_search.matchesLen(); - if (total != self.last_total) { + if (total != self.last_screen.total) { log.debug("notifying total matches={}", .{total}); - self.last_total = total; + self.last_screen.total = total; cb(.{ .total_matches = total }, ud); } @@ -666,6 +705,40 @@ const Search = struct { cb(.{ .viewport_matches = results.items }, ud); } + // Check our last selected match data. + if (screen_search.selected) |m| match: { + const flattened = screen_search.selectedMatch() orelse break :match; + const untracked = flattened.untracked(); + if (self.last_screen.selected) |prev| { + if (prev.idx == m.idx and prev.highlight.eql(untracked)) { + // Same selection, don't update it. + break :match; + } + } + + // New selection, notify! + self.last_screen.selected = .{ + .idx = m.idx, + .highlight = untracked, + }; + + log.debug("notifying selection updated idx={}", .{m.idx}); + cb( + .{ .selected_match = .{ + .idx = m.idx, + .highlight = flattened, + } }, + ud, + ); + } else if (self.last_screen.selected != null) { + log.debug("notifying selection cleared", .{}); + self.last_screen.selected = null; + cb( + .{ .selected_match = null }, + ud, + ); + } + // Send our complete notification if we just completed. if (!self.last_complete and self.isComplete()) { log.debug("notifying search complete", .{}); @@ -675,40 +748,42 @@ const Search = struct { } }; +const TestUserData = struct { + const Self = @This(); + reset: std.Thread.ResetEvent = .{}, + total: usize = 0, + selected: ?Event.SelectedMatch = null, + viewport: []FlattenedHighlight = &.{}, + + fn deinit(self: *Self) void { + for (self.viewport) |*hl| hl.deinit(testing.allocator); + testing.allocator.free(self.viewport); + } + + fn callback(event: Event, userdata: ?*anyopaque) void { + const ud: *Self = @ptrCast(@alignCast(userdata.?)); + switch (event) { + .quit => {}, + .complete => ud.reset.set(), + .total_matches => |v| ud.total = v, + .selected_match => |v| ud.selected = v, + .viewport_matches => |v| { + for (ud.viewport) |*hl| hl.deinit(testing.allocator); + testing.allocator.free(ud.viewport); + + ud.viewport = testing.allocator.alloc( + FlattenedHighlight, + v.len, + ) catch unreachable; + for (ud.viewport, v) |*dst, src| { + dst.* = src.clone(testing.allocator) catch unreachable; + } + }, + } + } +}; + test { - const UserData = struct { - const Self = @This(); - reset: std.Thread.ResetEvent = .{}, - total: usize = 0, - viewport: []FlattenedHighlight = &.{}, - - fn deinit(self: *Self) void { - for (self.viewport) |*hl| hl.deinit(testing.allocator); - testing.allocator.free(self.viewport); - } - - fn callback(event: Event, userdata: ?*anyopaque) void { - const ud: *Self = @ptrCast(@alignCast(userdata.?)); - switch (event) { - .quit => {}, - .complete => ud.reset.set(), - .total_matches => |v| ud.total = v, - .viewport_matches => |v| { - for (ud.viewport) |*hl| hl.deinit(testing.allocator); - testing.allocator.free(ud.viewport); - - ud.viewport = testing.allocator.alloc( - FlattenedHighlight, - v.len, - ) catch unreachable; - for (ud.viewport, v) |*dst, src| { - dst.* = src.clone(testing.allocator) catch unreachable; - } - }, - } - } - }; - const alloc = testing.allocator; var mutex: std.Thread.Mutex = .{}; var t: Terminal = try .init(alloc, .{ .cols = 20, .rows = 2 }); @@ -718,12 +793,12 @@ test { defer stream.deinit(); try stream.nextSlice("Hello, world"); - var ud: UserData = .{}; + var ud: TestUserData = .{}; defer ud.deinit(); var thread: Thread = try .init(alloc, .{ .mutex = &mutex, .terminal = &t, - .event_cb = &UserData.callback, + .event_cb = &TestUserData.callback, .event_userdata = &ud, }); defer thread.deinit(); diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 4c632646c..bd0e71476 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -57,7 +57,7 @@ pub const ScreenSearch = struct { history_results: std.ArrayList(FlattenedHighlight), active_results: std.ArrayList(FlattenedHighlight), - const SelectedMatch = struct { + pub const SelectedMatch = struct { /// Index from the end of the match list (0 = most recent match) idx: usize, From 880db9fdd08a82c39781153c106b977bb9f2c321 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 10:31:34 -0800 Subject: [PATCH 121/209] renderer: hook up search selection match highlighting --- src/Surface.zig | 27 +++++++++++++++++++++- src/config/Config.zig | 17 +++++++++++++- src/renderer/Thread.zig | 8 +++++++ src/renderer/generic.zig | 48 ++++++++++++++++++++++++++++++++++++++-- src/renderer/message.zig | 9 ++++++++ src/terminal/render.zig | 22 +++++++++++++----- 6 files changed, 122 insertions(+), 9 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index f0880d3c5..d23ae0ea7 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1363,6 +1363,32 @@ fn searchCallback_( try self.renderer_thread.wakeup.notify(); }, + .selected_match => |selected_| { + if (selected_) |sel| { + // Copy the flattened match. + var arena: ArenaAllocator = .init(self.alloc); + errdefer arena.deinit(); + const alloc = arena.allocator(); + const match = try sel.highlight.clone(alloc); + + _ = self.renderer_thread.mailbox.push( + .{ .search_selected_match = .{ + .arena = arena, + .match = match, + } }, + .forever, + ); + } else { + // Reset our selected match + _ = self.renderer_thread.mailbox.push( + .{ .search_selected_match = null }, + .forever, + ); + } + + try self.renderer_thread.wakeup.notify(); + }, + // When we quit, tell our renderer to reset any search state. .quit => { _ = self.renderer_thread.mailbox.push( @@ -1376,7 +1402,6 @@ fn searchCallback_( }, // Unhandled, so far. - .selected_match, .total_matches, .complete, => {}, diff --git a/src/config/Config.zig b/src/config/Config.zig index 89254b93f..13e44602a 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -988,10 +988,25 @@ palette: Palette = .{}, /// - "cell-foreground" to match the cell foreground color /// - "cell-background" to match the cell background color /// -/// The default value is +/// The default value is black text on a golden yellow background. @"search-foreground": TerminalColor = .{ .color = .{ .r = 0, .g = 0, .b = 0 } }, @"search-background": TerminalColor = .{ .color = .{ .r = 0xFF, .g = 0xE0, .b = 0x82 } }, +/// The foreground and background color for the currently selected search match. +/// This is the focused match that will be jumped to when using next/previous +/// search navigation. +/// +/// Valid values: +/// +/// - Hex (`#RRGGBB` or `RRGGBB`) +/// - Named X11 color +/// - "cell-foreground" to match the cell foreground color +/// - "cell-background" to match the cell background color +/// +/// The default value is black text on a bright orange background. +@"search-selected-foreground": TerminalColor = .{ .color = .{ .r = 0, .g = 0, .b = 0 } }, +@"search-selected-background": TerminalColor = .{ .color = .{ .r = 0xFE, .g = 0xA6, .b = 0x2B } }, + /// The command to run, usually a shell. If this is not an absolute path, it'll /// be looked up in the `PATH`. If this is not set, a default will be looked up /// from your system. The rules for the default lookup are: diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 738dce61c..7316ac51d 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -459,6 +459,14 @@ fn drainMailbox(self: *Thread) !void { self.renderer.search_matches_dirty = true; }, + .search_selected_match => |v| { + // Note we don't free the new value because we expect our + // allocators to match. + if (self.renderer.search_selected_match) |*m| m.arena.deinit(); + self.renderer.search_selected_match = v; + self.renderer.search_matches_dirty = true; + }, + .inspector => |v| self.flags.has_inspector = v, .macos_display_id => |v| { diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 7701a5418..bddda7ef0 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -130,6 +130,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// Note that the selections MAY BE INVALID (point to PageList nodes /// that do not exist anymore). These must be validated prior to use. search_matches: ?renderer.Message.SearchMatches, + search_selected_match: ?renderer.Message.SearchMatch, search_matches_dirty: bool, /// The current set of cells to render. This is rebuilt on every frame @@ -222,6 +223,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// a large screen. terminal_state_frame_count: usize = 0, + const HighlightTag = enum(u8) { + search_match, + search_match_selected, + }; + /// Swap chain which maintains multiple copies of the state needed to /// render a frame, so that we can start building the next frame while /// the previous frame is still being processed on the GPU. @@ -539,6 +545,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { selection_foreground: ?configpkg.Config.TerminalColor, search_background: configpkg.Config.TerminalColor, search_foreground: configpkg.Config.TerminalColor, + search_selected_background: configpkg.Config.TerminalColor, + search_selected_foreground: configpkg.Config.TerminalColor, bold_color: ?configpkg.BoldColor, faint_opacity: u8, min_contrast: f32, @@ -612,6 +620,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .selection_foreground = config.@"selection-foreground", .search_background = config.@"search-background", .search_foreground = config.@"search-foreground", + .search_selected_background = config.@"search-selected-background", + .search_selected_foreground = config.@"search-selected-foreground", .custom_shaders = custom_shaders, .bg_image = bg_image, @@ -687,6 +697,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .scrollbar = .zero, .scrollbar_dirty = false, .search_matches = null, + .search_selected_match = null, .search_matches_dirty = false, // Render state @@ -760,6 +771,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { pub fn deinit(self: *Self) void { self.terminal_state.deinit(self.alloc); + if (self.search_selected_match) |*m| m.arena.deinit(); if (self.search_matches) |*m| m.arena.deinit(); self.swap_chain.deinit(); @@ -1209,9 +1221,24 @@ pub fn Renderer(comptime GraphicsAPI: type) type { highlights.clearRetainingCapacity(); } + // NOTE: The order below matters. Highlights added earlier + // will take priority. + + if (self.search_selected_match) |m| { + self.terminal_state.updateHighlightsFlattened( + self.alloc, + @intFromEnum(HighlightTag.search_match_selected), + (&m.match)[0..1], + ) catch |err| { + // Not a critical error, we just won't show highlights. + log.warn("error updating search selected highlight err={}", .{err}); + }; + } + if (self.search_matches) |m| { self.terminal_state.updateHighlightsFlattened( self.alloc, + @intFromEnum(HighlightTag.search_match), m.matches, ) catch |err| { // Not a critical error, we just won't show highlights. @@ -2560,13 +2587,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type { false, selection, search, + search_selected, } = selected: { // If we're highlighted, then we're selected. In the // future we want to use a different style for this // but this to get started. for (highlights.items) |hl| { - if (x >= hl[0] and x <= hl[1]) { - break :selected .search; + if (x >= hl.range[0] and x <= hl.range[1]) { + const tag: HighlightTag = @enumFromInt(hl.tag); + break :selected switch (tag) { + .search_match => .search, + .search_match_selected => .search_selected, + }; } } @@ -2614,6 +2646,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, }, + .search_selected => switch (self.config.search_selected_background) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, + }, + // Not selected .false => if (style.flags.inverse != isCovering(cell.codepoint())) // Two cases cause us to invert (use the fg color as the bg) @@ -2652,6 +2690,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, }, + .search_selected => switch (self.config.search_selected_foreground) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, + }, + .false => if (style.flags.inverse) final_bg else diff --git a/src/renderer/message.zig b/src/renderer/message.zig index 8a319166b..8d4db32cd 100644 --- a/src/renderer/message.zig +++ b/src/renderer/message.zig @@ -58,6 +58,10 @@ pub const Message = union(enum) { /// viewport. The renderer must handle this gracefully. search_viewport_matches: SearchMatches, + /// The selected match from the search thread. May be null to indicate + /// no match currently. + search_selected_match: ?SearchMatch, + /// Activate or deactivate the inspector. inspector: bool, @@ -69,6 +73,11 @@ pub const Message = union(enum) { matches: []const terminal.highlight.Flattened, }; + pub const SearchMatch = struct { + arena: ArenaAllocator, + match: terminal.highlight.Flattened, + }; + /// Initialize a change_config message. pub fn initChangeConfig(alloc: Allocator, config: *const configpkg.Config) !Message { const thread_ptr = try alloc.create(renderer.Thread.DerivedConfig); diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 8f4da12eb..6acf88dcb 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -193,9 +193,17 @@ pub const RenderState = struct { /// The x range of the selection within this row. selection: ?[2]size.CellCountInt, - /// The x ranges of highlights within this row. Highlights are - /// applied after the update by calling `updateHighlights`. - highlights: std.ArrayList([2]size.CellCountInt), + /// The highlights within this row. + highlights: std.ArrayList(Highlight), + }; + + pub const Highlight = struct { + /// A special tag that can be used by the caller to differentiate + /// different highlight types. The value is opaque to the RenderState. + tag: u8, + + /// The x ranges of highlights within this row. + range: [2]size.CellCountInt, }; pub const Cell = struct { @@ -646,6 +654,7 @@ pub const RenderState = struct { pub fn updateHighlightsFlattened( self: *RenderState, alloc: Allocator, + tag: u8, hls: []const highlight.Flattened, ) Allocator.Error!void { // Fast path, we have no highlights! @@ -691,8 +700,11 @@ pub const RenderState = struct { try row_highlights.append( arena_alloc, .{ - if (i == 0) hl.top_x else 0, - if (i == nodes.len - 1) hl.bot_x else self.cols - 1, + .tag = tag, + .range = .{ + if (i == 0) hl.top_x else 0, + if (i == nodes.len - 1) hl.bot_x else self.cols - 1, + }, }, ); From ba7b816af09b5f676353896553538322cc442aa4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 10:48:31 -0800 Subject: [PATCH 122/209] core: bindings for navigate_search --- src/Surface.zig | 13 +++++++++++++ src/input/Binding.zig | 10 ++++++++++ src/input/command.zig | 10 ++++++++++ 3 files changed, 33 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index d23ae0ea7..4323291be 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4918,6 +4918,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .{ .change_needle = text }, .forever, ); + s.state.wakeup.notify() catch {}; + }, + + .navigate_search => |nav| { + const s: *Search = if (self.search) |*s| s else return false; + _ = s.state.mailbox.push( + .{ .select = switch (nav) { + .next => .next, + .previous => .prev, + } }, + .forever, + ); + s.state.wakeup.notify() catch {}; }, .copy_to_clipboard => |format| { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 1b681e725..ce60ea0e0 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -336,6 +336,10 @@ pub const Action = union(enum) { /// the search is canceled. If a previous search is active, it is replaced. search: []const u8, + /// Navigate the search results. If there is no active search, this + /// is not performed. + navigate_search: NavigateSearch, + /// Clear the screen and all scrollback. clear_screen, @@ -826,6 +830,11 @@ pub const Action = union(enum) { } }; + pub const NavigateSearch = enum { + previous, + next, + }; + pub const AdjustSelection = enum { left, right, @@ -1157,6 +1166,7 @@ pub const Action = union(enum) { .text, .cursor_key, .search, + .navigate_search, .reset, .copy_to_clipboard, .copy_url_to_clipboard, diff --git a/src/input/command.zig b/src/input/command.zig index 11f65cea3..a3df0e858 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -163,6 +163,16 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Paste the contents of the selection clipboard.", }}, + .navigate_search => comptime &.{ .{ + .action = .{ .navigate_search = .next }, + .title = "Next Search Result", + .description = "Navigate to the next search result, if any.", + }, .{ + .action = .{ .navigate_search = .previous }, + .title = "Previous Search Result", + .description = "Navigate to the previous search result, if any.", + } }, + .increase_font_size => comptime &.{.{ .action = .{ .increase_font_size = 1 }, .title = "Increase Font Size", From d0334b7ab606d498caf2a978fc7132df34d942cc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 11:00:32 -0800 Subject: [PATCH 123/209] search: scroll to selected search match --- src/terminal/search/Thread.zig | 11 +++++- src/terminal/search/screen.zig | 64 +++++++++++++++++++--------------- 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 2eea372e4..e6094b8e5 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -257,7 +257,16 @@ fn select(self: *Thread, sel: ScreenSearch.Select) !void { // The selection will trigger a selection change notification // if it did change. - try screen_search.select(sel); + if (try screen_search.select(sel)) scroll: { + if (screen_search.selected) |m| { + // Selection changed, let's scroll the viewport to see it + // since we have the lock anyways. + const screen = self.opts.terminal.screens.get( + s.last_screen.key, + ) orelse break :scroll; + screen.scroll(.{ .pin = m.highlight.start.* }); + } + } } /// Change the search term to the given value. diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index bd0e71476..7645feead 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -398,8 +398,10 @@ pub const ScreenSearch = struct { self.selected = null; break :select_prev true; }; - defer if (select_prev) self.select(.prev) catch |err| { - log.info("reload failed to reset search selection err={}", .{err}); + defer if (select_prev) { + _ = self.select(.prev) catch |err| { + log.info("reload failed to reset search selection err={}", .{err}); + }; }; const alloc = self.allocator(); @@ -526,7 +528,7 @@ pub const ScreenSearch = struct { if (self.selected) |*m| { m.deinit(self.screen); self.selected = null; - self.select(.next) catch |err| { + _ = self.select(.next) catch |err| { log.info("reload failed to reset search selection err={}", .{err}); }; } @@ -578,7 +580,7 @@ pub const ScreenSearch = struct { // No match, just go back to the first match. m.deinit(self.screen); self.selected = null; - self.select(.next) catch |err| { + _ = self.select(.next) catch |err| { log.info("reload failed to reset search selection err={}", .{err}); }; } @@ -615,20 +617,20 @@ pub const ScreenSearch = struct { /// Select the next or previous search result. This requires read/write /// access to the underlying screen, since we utilize tracked pins to /// ensure our selection sticks with contents changing. - pub fn select(self: *ScreenSearch, to: Select) Allocator.Error!void { + pub fn select(self: *ScreenSearch, to: Select) Allocator.Error!bool { // All selection requires valid pins so we prune history and // reload our active area immediately. This ensures all search // results point to valid nodes. try self.reloadActive(); self.pruneHistory(); - switch (to) { + return switch (to) { .next => try self.selectNext(), .prev => try self.selectPrev(), - } + }; } - fn selectNext(self: *ScreenSearch) Allocator.Error!void { + fn selectNext(self: *ScreenSearch) Allocator.Error!bool { // Get our previous match so we can change it. If we have no // prior match, we have the easy task of getting the first. var prev = if (self.selected) |*m| m else { @@ -643,7 +645,7 @@ pub const ScreenSearch = struct { break :hl self.history_results.items[0]; } else { // No matches at all. Can't select anything. - return; + return false; } }; @@ -657,7 +659,7 @@ pub const ScreenSearch = struct { .idx = 0, .highlight = tracked, }; - return; + return true; }; const next_idx = prev.idx + 1; @@ -665,7 +667,7 @@ pub const ScreenSearch = struct { const history_len = self.history_results.items.len; if (next_idx >= active_len + history_len) { // No more matches. We don't wrap or reset the match currently. - return; + return false; } const hl: FlattenedHighlight = if (next_idx < active_len) self.active_results.items[active_len - 1 - next_idx] @@ -682,9 +684,11 @@ pub const ScreenSearch = struct { .idx = next_idx, .highlight = tracked, }; + + return true; } - fn selectPrev(self: *ScreenSearch) Allocator.Error!void { + fn selectPrev(self: *ScreenSearch) Allocator.Error!bool { // Get our previous match so we can change it. If we have no // prior match, we have the easy task of getting the last. var prev = if (self.selected) |*m| m else { @@ -699,7 +703,7 @@ pub const ScreenSearch = struct { break :hl self.active_results.items[0]; } else { // No matches at all. Can't select anything. - return; + return false; } }; @@ -715,13 +719,13 @@ pub const ScreenSearch = struct { .idx = active_len + history_len - 1, .highlight = tracked, }; - return; + return true; }; // Can't go below zero if (prev.idx == 0) { // No more matches. We don't wrap or reset the match currently. - return; + return false; } const next_idx = prev.idx - 1; @@ -741,6 +745,8 @@ pub const ScreenSearch = struct { .idx = next_idx, .highlight = tracked, }; + + return true; } }; @@ -972,7 +978,7 @@ test "select next" { // Select our next match (first) try search.searchAll(); - try search.select(.next); + _ = try search.select(.next); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -986,7 +992,7 @@ test "select next" { } // Next match - try search.select(.next); + _ = try search.select(.next); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -1000,7 +1006,7 @@ test "select next" { } // Next match (no wrap) - try search.select(.next); + _ = try search.select(.next); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -1026,8 +1032,8 @@ test "select in active changes contents completely" { var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); try search.searchAll(); - try search.select(.next); - try search.select(.next); + _ = try search.select(.next); + _ = try search.select(.next); { // Initial selection is the first fizz const sel = search.selectedMatch().?.untracked(); @@ -1101,7 +1107,7 @@ test "select into history" { try search.searchAll(); // Get all matches - try search.select(.next); + _ = try search.select(.next); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -1167,7 +1173,7 @@ test "select prev" { // Select prev (oldest first) try search.searchAll(); - try search.select(.prev); + _ = try search.select(.prev); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -1181,7 +1187,7 @@ test "select prev" { } // Prev match (towards newest) - try search.select(.prev); + _ = try search.select(.prev); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -1195,7 +1201,7 @@ test "select prev" { } // Prev match (no wrap, stays at newest) - try search.select(.prev); + _ = try search.select(.prev); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -1223,7 +1229,7 @@ test "select prev then next" { try search.searchAll(); // Select next (newest first) - try search.select(.next); + _ = try search.select(.next); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -1233,7 +1239,7 @@ test "select prev then next" { } // Select next (older) - try search.select(.next); + _ = try search.select(.next); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -1243,7 +1249,7 @@ test "select prev then next" { } // Select prev (back to newer) - try search.select(.prev); + _ = try search.select(.prev); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -1276,7 +1282,7 @@ test "select prev with history" { try search.searchAll(); // Select prev (oldest first, should be in history) - try search.select(.prev); + _ = try search.select(.prev); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -1290,7 +1296,7 @@ test "select prev with history" { } // Select prev (towards newer, should move to active area) - try search.select(.prev); + _ = try search.select(.prev); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .active = .{ From 7fba2da4048a19f67fce53f0dcceaba98d27fc78 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 11:05:11 -0800 Subject: [PATCH 124/209] better default search match color --- src/config/Config.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 13e44602a..753a2d697 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1003,9 +1003,9 @@ palette: Palette = .{}, /// - "cell-foreground" to match the cell foreground color /// - "cell-background" to match the cell background color /// -/// The default value is black text on a bright orange background. +/// The default value is black text on a soft peach background. @"search-selected-foreground": TerminalColor = .{ .color = .{ .r = 0, .g = 0, .b = 0 } }, -@"search-selected-background": TerminalColor = .{ .color = .{ .r = 0xFE, .g = 0xA6, .b = 0x2B } }, +@"search-selected-background": TerminalColor = .{ .color = .{ .r = 0xF2, .g = 0xA5, .b = 0x7E } }, /// The command to run, usually a shell. If this is not an absolute path, it'll /// be looked up in the `PATH`. If this is not set, a default will be looked up From 53d0abf4dca426d110f8747a5c85e71042c457fb Mon Sep 17 00:00:00 2001 From: Dominique Martinet Date: Wed, 26 Nov 2025 12:47:43 +0000 Subject: [PATCH 125/209] apprt/gtk: (clipboard) fix GTK internal paste of UTF-8 content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When pasting text in GTK, the current version properly prioritizes text/plain;charset=utf-8 when the content is offered by another application, but when pasting from ghostty to itself the mime type selection algorithm prefers the offer order and matches `text/plain`, which then converts non-ASCII UTF-8 into a bunch of escaped hex characters (e.g. 日本語 becomes \E6\97\A5\E6\9C\AC\E8\AA\9E) This is being discussed on the GTK side[1], but until everyone gets an updated GTK it cannot hurt to offer the UTF-8 variant first (and one of the GTK dev claims it actually is a bug not to do it, but the wayland spec is not clear about it, so other clients could behave similarly) Link: https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/9189 [1] Fixes #9682 --- src/apprt/gtk/class/surface.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 291a405ce..53463b2fc 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3369,12 +3369,16 @@ const Clipboard = struct { // text/plain type. The default charset when there is // none is ASCII, and lots of things look for UTF-8 // specifically. + // The specs are not clear about the order here, but + // some clients apparently pick the first match in the + // order we set here then garble up bare 'text/plain' + // with non-ASCII UTF-8 content, so offer UTF-8 first. // // Note that under X11, GTK automatically adds the // UTF8_STRING atom when this is present. const text_provider_atoms = [_][:0]const u8{ - "text/plain", "text/plain;charset=utf-8", + "text/plain", }; var text_providers: [text_provider_atoms.len]*gdk.ContentProvider = undefined; for (text_provider_atoms, 0..) |atom, j| { From 8d11335ee4d04f4927198f990e8ee2fbbfc3b158 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 08:04:48 -0800 Subject: [PATCH 126/209] terminal: PageList stores serial number for page nodes --- src/terminal/PageList.zig | 51 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 53c0c346b..72fb3bb8e 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -43,6 +43,7 @@ const Node = struct { prev: ?*Node = null, next: ?*Node = null, data: Page, + serial: u64, }; /// The memory pool we get page nodes from. @@ -113,6 +114,20 @@ pool_owned: bool, /// The list of pages in the screen. pages: List, +/// A monotonically increasing serial number that is incremented each +/// time a page is allocated or reused as new. The serial is assigned to +/// the Node. +/// +/// The serial number can be used to detect whether the page is identical +/// to the page that was originally referenced by a pointer. Since we reuse +/// and pool memory, pointer stability is not guaranteed, but the serial +/// will always be different for different allocations. +/// +/// Developer note: we never do overflow checking on this. If we created +/// a new page every second it'd take 584 billion years to overflow. We're +/// going to risk it. +page_serial: u64, + /// Byte size of the total amount of allocated pages. Note this does /// not include the total allocated amount in the pool which may be more /// than this due to preheating. @@ -264,7 +279,13 @@ pub fn init( // necessary. var pool = try MemoryPool.init(alloc, std.heap.page_allocator, page_preheat); errdefer pool.deinit(); - const page_list, const page_size = try initPages(&pool, cols, rows); + var page_serial: u64 = 0; + const page_list, const page_size = try initPages( + &pool, + &page_serial, + cols, + rows, + ); // Get our minimum max size, see doc comments for more details. const min_max_size = try minMaxSize(cols, rows); @@ -282,6 +303,7 @@ pub fn init( .pool = pool, .pool_owned = true, .pages = page_list, + .page_serial = page_serial, .page_size = page_size, .explicit_max_size = max_size orelse std.math.maxInt(usize), .min_max_size = min_max_size, @@ -297,6 +319,7 @@ pub fn init( fn initPages( pool: *MemoryPool, + serial: *u64, cols: size.CellCountInt, rows: size.CellCountInt, ) !struct { List, usize } { @@ -323,6 +346,7 @@ fn initPages( .init(page_buf), Page.layout(cap), ), + .serial = serial.*, }; node.data.size.rows = @min(rem, node.data.capacity.rows); rem -= node.data.size.rows; @@ -330,6 +354,9 @@ fn initPages( // Add the page to the list page_list.append(node); page_size += page_buf.len; + + // Increment our serial + serial.* += 1; } assert(page_list.first != null); @@ -523,6 +550,7 @@ pub fn reset(self: *PageList) void { // we retained the capacity for the minimum number of pages we need. self.pages, self.page_size = initPages( &self.pool, + &self.page_serial, self.cols, self.rows, ) catch @panic("initPages failed"); @@ -638,6 +666,7 @@ pub fn clone( } // Copy our pages + var page_serial: u64 = 0; var total_rows: usize = 0; var page_size: usize = 0; while (it.next()) |chunk| { @@ -646,6 +675,7 @@ pub fn clone( const node = try createPageExt( pool, chunk.node.data.capacity, + &page_serial, &page_size, ); assert(node.data.capacity.rows >= chunk.end - chunk.start); @@ -690,6 +720,7 @@ pub fn clone( .alloc => true, }, .pages = page_list, + .page_serial = page_serial, .page_size = page_size, .explicit_max_size = self.explicit_max_size, .min_max_size = self.min_max_size, @@ -2431,6 +2462,10 @@ pub fn grow(self: *PageList) !?*List.Node { first.data.size.rows = 1; self.pages.insertAfter(last, first); + // We also need to reset the serial number + first.serial = self.page_serial; + self.page_serial += 1; + // Update any tracked pins that point to this page to point to the // new first page to the top-left. const pin_keys = self.tracked_pins.keys(); @@ -2570,12 +2605,18 @@ inline fn createPage( cap: Capacity, ) Allocator.Error!*List.Node { // log.debug("create page cap={}", .{cap}); - return try createPageExt(&self.pool, cap, &self.page_size); + return try createPageExt( + &self.pool, + cap, + &self.page_serial, + &self.page_size, + ); } inline fn createPageExt( pool: *MemoryPool, cap: Capacity, + serial: *u64, total_size: ?*usize, ) Allocator.Error!*List.Node { var page = try pool.nodes.create(); @@ -2605,8 +2646,12 @@ inline fn createPageExt( // to undefined, 0xAA. if (comptime std.debug.runtime_safety) @memset(page_buf, 0); - page.* = .{ .data = .initBuf(.init(page_buf), layout) }; + page.* = .{ + .data = .initBuf(.init(page_buf), layout), + .serial = serial.*, + }; page.data.size.rows = 0; + serial.* += 1; if (total_size) |v| { // Accumulate page size now. We don't assert or check max size From 1786022ac3a1b5efd0c2a7467b416e6c06051d3d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 08:31:06 -0800 Subject: [PATCH 127/209] terminal: ScreenSearch restarts on resize --- src/terminal/search/screen.zig | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 7645feead..7e45eeec5 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -4,6 +4,7 @@ const testing = std.testing; const Allocator = std.mem.Allocator; const point = @import("../point.zig"); const highlight = @import("../highlight.zig"); +const size = @import("../size.zig"); const FlattenedHighlight = highlight.Flattened; const TrackedHighlight = highlight.Tracked; const PageList = @import("../PageList.zig"); @@ -57,6 +58,11 @@ pub const ScreenSearch = struct { history_results: std.ArrayList(FlattenedHighlight), active_results: std.ArrayList(FlattenedHighlight), + /// The dimensions of the screen. When this changes we need to + /// restart the whole search, currently. + rows: size.CellCountInt, + cols: size.CellCountInt, + pub const SelectedMatch = struct { /// Index from the end of the match list (0 = most recent match) idx: usize, @@ -129,6 +135,8 @@ pub const ScreenSearch = struct { ) Allocator.Error!ScreenSearch { var result: ScreenSearch = .{ .screen = screen, + .rows = screen.pages.rows, + .cols = screen.pages.cols, .active = try .init(alloc, needle_unowned), .history = null, .state = .active, @@ -247,6 +255,29 @@ pub const ScreenSearch = struct { /// Feed on a complete screen search will perform some cleanup of /// potentially stale history results (pruned) and reclaim some memory. pub fn feed(self: *ScreenSearch) Allocator.Error!void { + // If the screen resizes, we have to reset our entire search. That + // isn't ideal but we don't have a better way right now to handle + // reflowing the search results beyond putting a tracked pin for + // every single result. + if (self.screen.pages.rows != self.rows or + self.screen.pages.cols != self.cols) + { + // Reinit + const new: ScreenSearch = try .init( + self.allocator(), + self.screen, + self.needle(), + ); + + // Deinit/reinit + self.deinit(); + self.* = new; + + // New result should have matching dimensions + assert(self.screen.pages.rows == self.rows); + assert(self.screen.pages.cols == self.cols); + } + const history: *PageListSearch = if (self.history) |*h| &h.searcher else { // No history to feed, search is complete. self.state = .complete; From e549af76fe6e91305b86f1adc098566de6b4a2a7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 08:36:29 -0800 Subject: [PATCH 128/209] terminal: flattened highlights contain serial numbers for nodes --- src/terminal/highlight.zig | 21 +++++++++++++++++---- src/terminal/search/sliding_window.zig | 6 ++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/terminal/highlight.zig b/src/terminal/highlight.zig index 13c00b48e..4db5e31e7 100644 --- a/src/terminal/highlight.zig +++ b/src/terminal/highlight.zig @@ -114,7 +114,7 @@ pub const Flattened = struct { /// The page chunks that make up this highlight. This handles the /// y bounds since chunks[0].start is the first highlighted row /// and chunks[len - 1].end is the last highlighted row (exclsive). - chunks: std.MultiArrayList(PageChunk), + chunks: std.MultiArrayList(Chunk), /// The x bounds of the highlight. `bot_x` may be less than `top_x` /// for typical left-to-right highlights: can start the selection right @@ -122,8 +122,16 @@ pub const Flattened = struct { top_x: size.CellCountInt, bot_x: size.CellCountInt, - /// Exposed for easier type references. - pub const Chunk = PageChunk; + /// A flattened chunk is almost identical to a PageList.Chunk but + /// we also flatten the serial number. This lets the flattened + /// highlight more robust for comparisons and validity checks with + /// the PageList. + pub const Chunk = struct { + node: *PageList.List.Node, + serial: u64, + start: size.CellCountInt, + end: size.CellCountInt, + }; pub const empty: Flattened = .{ .chunks = .empty, @@ -139,7 +147,12 @@ pub const Flattened = struct { var result: std.MultiArrayList(PageChunk) = .empty; errdefer result.deinit(alloc); var it = start.pageIterator(.right_down, end); - while (it.next()) |chunk| try result.append(alloc, chunk); + while (it.next()) |chunk| try result.append(alloc, .{ + .node = chunk.node, + .serial = chunk.node.serial, + .start = chunk.start, + .end = chunk.end, + }); return .{ .chunks = result, .top_x = start.x, diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index ff0fa0277..66f7bc70c 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -87,6 +87,7 @@ pub const SlidingWindow = struct { const MetaBuf = CircBuf(Meta, undefined); const Meta = struct { node: *PageList.List.Node, + serial: u64, cell_map: std.ArrayList(point.Coordinate), pub fn deinit(self: *Meta, alloc: Allocator) void { @@ -345,6 +346,7 @@ pub const SlidingWindow = struct { result.bot_x = end_map.x; self.chunk_buf.appendAssumeCapacity(.{ .node = meta.node, + .serial = meta.serial, .start = @intCast(start_map.y), .end = @intCast(end_map.y + 1), }); @@ -363,6 +365,7 @@ pub const SlidingWindow = struct { result.top_x = map.x; self.chunk_buf.appendAssumeCapacity(.{ .node = meta.node, + .serial = meta.serial, .start = @intCast(map.y), .end = meta.node.data.size.rows, }); @@ -397,6 +400,7 @@ pub const SlidingWindow = struct { // to our results because we want the full flattened list. self.chunk_buf.appendAssumeCapacity(.{ .node = meta.node, + .serial = meta.serial, .start = 0, .end = meta.node.data.size.rows, }); @@ -410,6 +414,7 @@ pub const SlidingWindow = struct { result.bot_x = map.x; self.chunk_buf.appendAssumeCapacity(.{ .node = meta.node, + .serial = meta.serial, .start = 0, .end = @intCast(map.y + 1), }); @@ -513,6 +518,7 @@ pub const SlidingWindow = struct { // Initialize our metadata for the node. var meta: Meta = .{ .node = node, + .serial = node.serial, .cell_map = .empty, }; errdefer meta.deinit(self.alloc); From 30f189d774dc970e640bcdd9bcda035d69b2947b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 08:41:26 -0800 Subject: [PATCH 129/209] terminal: PageList has page_serial_min --- src/terminal/PageList.zig | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 72fb3bb8e..3673cf1f4 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -128,6 +128,10 @@ pages: List, /// going to risk it. page_serial: u64, +/// The lowest still valid serial number that could exist. This allows +/// for quick comparisons to find invalid pages in references. +page_serial_min: u64, + /// Byte size of the total amount of allocated pages. Note this does /// not include the total allocated amount in the pool which may be more /// than this due to preheating. @@ -304,6 +308,7 @@ pub fn init( .pool_owned = true, .pages = page_list, .page_serial = page_serial, + .page_serial_min = 0, .page_size = page_size, .explicit_max_size = max_size orelse std.math.maxInt(usize), .min_max_size = min_max_size, @@ -390,6 +395,7 @@ pub inline fn pauseIntegrityChecks(self: *PageList, pause: bool) void { const IntegrityError = error{ TotalRowsMismatch, ViewportPinOffsetMismatch, + PageSerialInvalid, }; /// Verify the integrity of the PageList. This is expensive and should @@ -401,8 +407,27 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void { // Our viewport pin should never be garbage assert(!self.viewport_pin.garbage); + // Grab our total rows + var actual_total: usize = 0; + { + var node_ = self.pages.first; + while (node_) |node| { + actual_total += node.data.size.rows; + node_ = node.next; + + // While doing this traversal, verify no node has a serial + // number lower than our min. + if (node.serial < self.page_serial_min) { + log.warn( + "PageList integrity violation: page serial too low serial={} min={}", + .{ node.serial, self.page_serial_min }, + ); + return IntegrityError.PageSerialInvalid; + } + } + } + // Verify that our cached total_rows matches the actual row count - const actual_total = self.totalRows(); if (actual_total != self.total_rows) { log.warn( "PageList integrity violation: total_rows mismatch cached={} actual={}", @@ -721,6 +746,7 @@ pub fn clone( }, .pages = page_list, .page_serial = page_serial, + .page_serial_min = 0, .page_size = page_size, .explicit_max_size = self.explicit_max_size, .min_max_size = self.min_max_size, @@ -2462,7 +2488,11 @@ pub fn grow(self: *PageList) !?*List.Node { first.data.size.rows = 1; self.pages.insertAfter(last, first); - // We also need to reset the serial number + // We also need to reset the serial number. Since this is the only + // place we ever reuse a serial number, we also can safely set + // page_serial_min to be one more than the old serial because we + // only ever prune the oldest pages. + self.page_serial_min = first.serial + 1; first.serial = self.page_serial; self.page_serial += 1; From 9b7753a36f244dded08f4c6e79767ae680211027 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 08:45:38 -0800 Subject: [PATCH 130/209] terminal: ScreenSearch prunes by min serial --- src/terminal/search/screen.zig | 52 +++++++--------------------------- 1 file changed, 11 insertions(+), 41 deletions(-) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 7e45eeec5..bd5aa80a5 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -313,49 +313,19 @@ pub const ScreenSearch = struct { } fn pruneHistory(self: *ScreenSearch) void { - const history: *PageListSearch = if (self.history) |*h| &h.searcher else return; - - // Keep track of the last checked node to avoid redundant work. - var last_checked: ?*PageList.List.Node = null; - - // Go through our history results in reverse order to find - // the oldest matches first (since oldest nodes are pruned first). - for (0..self.history_results.items.len) |rev_i| { - const i = self.history_results.items.len - 1 - rev_i; - const node = node: { - const hl = &self.history_results.items[i]; - break :node hl.chunks.items(.node)[0]; - }; - - // If this is the same node as what we last checked and - // found to prune, then continue until we find the first - // non-matching, non-pruned node so we can prune the older - // ones. - if (last_checked == node) continue; - last_checked = node; - - // Try to find this node in the PageList using a standard - // O(N) traversal. This isn't as bad as it seems because our - // oldest matches are likely to be near the start of the - // list and as soon as we find one we're done. - var it = history.list.pages.first; - while (it) |valid_node| : (it = valid_node.next) { - if (valid_node != node) continue; - - // This is a valid node. If we're not at rev_i 0 then - // it means we have some data to prune! If we are - // at rev_i 0 then we can break out because there - // is nothing to prune. - if (rev_i == 0) return; - - // Prune the last rev_i items. + // Go through our history results in order (newest to oldest) to find + // any result that contains an invalid serial. Prune up to that + // point. + for (0..self.history_results.items.len) |i| { + const hl = &self.history_results.items[i]; + const serials = hl.chunks.items(.serial); + const lowest = serials[0]; + if (lowest < self.screen.pages.page_serial_min) { + // Everything from here forward we assume is invalid because + // our history results only get older. const alloc = self.allocator(); - for (self.history_results.items[i + 1 ..]) |*prune_hl| { - prune_hl.deinit(alloc); - } + for (self.history_results.items[i..]) |*prune_hl| prune_hl.deinit(alloc); self.history_results.shrinkAndFree(alloc, i); - - // Once we've pruned, future results can't be invalid. return; } } From b87d57f029c196f6d808e1a97fbe28692d826706 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 11:44:16 -0800 Subject: [PATCH 131/209] macos: search overlay --- macos/Sources/Ghostty/SurfaceView.swift | 112 ++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 0358f765b..f7cc455fc 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -198,6 +198,9 @@ extension Ghostty { } #endif + // Search overlay + SurfaceSearchOverlay() + // Show bell border if enabled if (ghostty.config.bellFeatures.contains(.border)) { BellBorderOverlay(bell: surfaceView.bell) @@ -382,6 +385,115 @@ extension Ghostty { } } + /// Search overlay view that displays a search bar with input field and navigation buttons. + struct SurfaceSearchOverlay: View { + @State private var searchText: String = "" + @State private var corner: Corner = .topRight + @State private var dragOffset: CGSize = .zero + @State private var barSize: CGSize = .zero + + private let padding: CGFloat = 8 + + var body: some View { + GeometryReader { geo in + HStack(spacing: 8) { + TextField("Search", text: $searchText) + .textFieldStyle(.plain) + .frame(width: 180) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color.primary.opacity(0.1)) + .cornerRadius(6) + + Button(action: {}) { + Image(systemName: "chevron.up") + } + .buttonStyle(.borderless) + + Button(action: {}) { + Image(systemName: "chevron.down") + } + .buttonStyle(.borderless) + + Button(action: {}) { + Image(systemName: "xmark") + } + .buttonStyle(.borderless) + } + .padding(8) + .background(.background) + .cornerRadius(8) + .shadow(radius: 4) + .background( + GeometryReader { barGeo in + Color.clear.onAppear { + barSize = barGeo.size + } + } + ) + .padding(padding) + .offset(dragOffset) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: corner.alignment) + .gesture( + DragGesture() + .onChanged { value in + dragOffset = value.translation + } + .onEnded { value in + let centerPos = centerPosition(for: corner, in: geo.size, barSize: barSize) + let newCenter = CGPoint( + x: centerPos.x + value.translation.width, + y: centerPos.y + value.translation.height + ) + corner = closestCorner(to: newCenter, in: geo.size) + dragOffset = .zero + } + ) + .animation(.easeOut(duration: 0.2), value: corner) + } + } + + enum Corner { + case topLeft, topRight, bottomLeft, bottomRight + + var alignment: Alignment { + switch self { + case .topLeft: return .topLeading + case .topRight: return .topTrailing + case .bottomLeft: return .bottomLeading + case .bottomRight: return .bottomTrailing + } + } + } + + private func centerPosition(for corner: Corner, in containerSize: CGSize, barSize: CGSize) -> CGPoint { + let halfWidth = barSize.width / 2 + padding + let halfHeight = barSize.height / 2 + padding + + switch corner { + case .topLeft: + return CGPoint(x: halfWidth, y: halfHeight) + case .topRight: + return CGPoint(x: containerSize.width - halfWidth, y: halfHeight) + case .bottomLeft: + return CGPoint(x: halfWidth, y: containerSize.height - halfHeight) + case .bottomRight: + return CGPoint(x: containerSize.width - halfWidth, y: containerSize.height - halfHeight) + } + } + + private func closestCorner(to point: CGPoint, in containerSize: CGSize) -> Corner { + let midX = containerSize.width / 2 + let midY = containerSize.height / 2 + + if point.x < midX { + return point.y < midY ? .topLeft : .bottomLeft + } else { + return point.y < midY ? .topRight : .bottomRight + } + } + } + /// A surface is terminology in Ghostty for a terminal surface, or a place where a terminal is actually drawn /// and interacted with. The word "surface" is used because a surface may represent a window, a tab, /// a split, a small preview pane, etc. It is ANYTHING that has a terminal drawn to it. From aeaa8d4ead6727fe9ee63c9785b5a72f7aeb4c7b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 11:57:34 -0800 Subject: [PATCH 132/209] add start_search binding and apprt action --- include/ghostty.h | 7 +++++++ src/Surface.zig | 11 +++++++++++ src/apprt/action.zig | 19 +++++++++++++++++++ src/input/Binding.zig | 5 +++++ src/input/command.zig | 6 ++++++ 5 files changed, 48 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 9b7a918ec..8c4455564 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -747,6 +747,11 @@ typedef struct { uint64_t duration; } ghostty_action_command_finished_s; +// apprt.action.StartSearch.C +typedef struct { + const char* needle; +} ghostty_action_start_search_s; + // terminal.Scrollbar typedef struct { uint64_t total; @@ -811,6 +816,7 @@ typedef enum { GHOSTTY_ACTION_PROGRESS_REPORT, GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD, GHOSTTY_ACTION_COMMAND_FINISHED, + GHOSTTY_ACTION_START_SEARCH, } ghostty_action_tag_e; typedef union { @@ -844,6 +850,7 @@ typedef union { ghostty_surface_message_childexited_s child_exited; ghostty_action_progress_report_s progress_report; ghostty_action_command_finished_s command_finished; + ghostty_action_start_search_s start_search; } ghostty_action_u; typedef struct { diff --git a/src/Surface.zig b/src/Surface.zig index 4323291be..1e1363229 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4877,6 +4877,17 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool self.renderer_state.terminal.fullReset(); }, + .start_search => if (self.search == null) { + // To save resources, we don't actually start a search here, + // we just notify teh apprt. The real thread will start when + // the first needles are set. + _ = try self.rt_app.performAction( + .{ .surface = self }, + .start_search, + .{ .needle = "" }, + ); + } else return false, + .search => |text| search: { const s: *Search = if (self.search) |*s| s else init: { // If we're stopping the search and we had no prior search, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 11186f059..45fa8aca0 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -301,6 +301,9 @@ pub const Action = union(Key) { /// A command has finished, command_finished: CommandFinished, + /// Start the search overlay with an optional initial needle. + start_search: StartSearch, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -358,6 +361,7 @@ pub const Action = union(Key) { progress_report, show_on_screen_keyboard, command_finished, + start_search, }; /// Sync with: ghostty_action_u @@ -770,3 +774,18 @@ pub const CommandFinished = struct { }; } }; + +pub const StartSearch = struct { + needle: [:0]const u8, + + // Sync with: ghostty_action_start_search_s + pub const C = extern struct { + needle: [*:0]const u8, + }; + + pub fn cval(self: StartSearch) C { + return .{ + .needle = self.needle.ptr, + }; + } +}; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index ce60ea0e0..636f343e3 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -340,6 +340,10 @@ pub const Action = union(enum) { /// is not performed. navigate_search: NavigateSearch, + /// Start a search if it isn't started already. This doesn't set any + /// search terms, but opens the UI for searching. + start_search, + /// Clear the screen and all scrollback. clear_screen, @@ -1167,6 +1171,7 @@ pub const Action = union(enum) { .cursor_key, .search, .navigate_search, + .start_search, .reset, .copy_to_clipboard, .copy_url_to_clipboard, diff --git a/src/input/command.zig b/src/input/command.zig index a3df0e858..37dc08fb4 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -163,6 +163,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Paste the contents of the selection clipboard.", }}, + .start_search => comptime &.{.{ + .action = .start_search, + .title = "Start Search", + .description = "Start a search if one isn't already active.", + }}, + .navigate_search => comptime &.{ .{ .action = .{ .navigate_search = .next }, .title = "Next Search Result", From bc44b187d6b1ab5436691c9d7a2848a0b11be81d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:02:27 -0800 Subject: [PATCH 133/209] macos: hook up start_search apprt action to open search --- macos/Sources/Ghostty/Ghostty.Action.swift | 12 +++++++ macos/Sources/Ghostty/Ghostty.App.swift | 26 +++++++++++++++ macos/Sources/Ghostty/SurfaceView.swift | 33 ++++++++++++++++--- .../Sources/Ghostty/SurfaceView_AppKit.swift | 3 ++ 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index 9d389a8c2..8fce2199d 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -115,6 +115,18 @@ extension Ghostty.Action { len = c.len } } + + struct StartSearch { + let needle: String? + + init(c: ghostty_action_start_search_s) { + if let needleCString = c.needle { + self.needle = String(cString: needleCString) + } else { + self.needle = nil + } + } + } } // Putting the initializer in an extension preserves the automatic one. diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 9c19199e8..5c62e7040 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -606,6 +606,9 @@ extension Ghostty { case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: closeAllWindows(app, target: target) + case GHOSTTY_ACTION_START_SEARCH: + startSearch(app, target: target, v: action.action.start_search) + case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: fallthrough case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS: @@ -1641,6 +1644,29 @@ extension Ghostty { } } + private static func startSearch( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_start_search_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("start_search does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + let startSearch = Ghostty.Action.StartSearch(c: v) + DispatchQueue.main.async { + surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch) + } + + default: + assertionFailure() + } + } + private static func configReload( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index f7cc455fc..dabfb4a57 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -197,10 +197,12 @@ extension Ghostty { SecureInputOverlay() } #endif - + // Search overlay - SurfaceSearchOverlay() - + if surfaceView.searchState != nil { + SurfaceSearchOverlay(searchState: $surfaceView.searchState) + } + // Show bell border if enabled if (ghostty.config.bellFeatures.contains(.border)) { BellBorderOverlay(bell: surfaceView.bell) @@ -387,10 +389,12 @@ extension Ghostty { /// Search overlay view that displays a search bar with input field and navigation buttons. struct SurfaceSearchOverlay: View { + @Binding var searchState: SurfaceView.SearchState? @State private var searchText: String = "" @State private var corner: Corner = .topRight @State private var dragOffset: CGSize = .zero @State private var barSize: CGSize = .zero + @FocusState private var isSearchFieldFocused: Bool private let padding: CGFloat = 8 @@ -404,6 +408,7 @@ extension Ghostty { .padding(.vertical, 6) .background(Color.primary.opacity(0.1)) .cornerRadius(6) + .focused($isSearchFieldFocused) Button(action: {}) { Image(systemName: "chevron.up") @@ -415,7 +420,9 @@ extension Ghostty { } .buttonStyle(.borderless) - Button(action: {}) { + Button(action: { + searchState = nil + }) { Image(systemName: "xmark") } .buttonStyle(.borderless) @@ -424,6 +431,12 @@ extension Ghostty { .background(.background) .cornerRadius(8) .shadow(radius: 4) + .onAppear { + if let needle = searchState?.needle { + searchText = needle + } + isSearchFieldFocused = true + } .background( GeometryReader { barGeo in Color.clear.onAppear { @@ -770,3 +783,15 @@ extension FocusedValues { typealias Value = OSSize } } + +// MARK: Search State + +extension Ghostty.SurfaceView { + class SearchState: ObservableObject { + @Published var needle: String = "" + + init(from startSearch: Ghostty.Action.StartSearch) { + self.needle = startSearch.needle ?? "" + } + } +} diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 6e3597fd3..19054b6c3 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -64,6 +64,9 @@ extension Ghostty { // The currently active key sequence. The sequence is not active if this is empty. @Published var keySequence: [KeyboardShortcut] = [] + // The current search state. When non-nil, the search overlay should be shown. + @Published var searchState: SearchState? = nil + // The time this surface last became focused. This is a ContinuousClock.Instant // on supported platforms. @Published var focusInstant: ContinuousClock.Instant? = nil From b084889782d1e282dc776cd21deec3b9262fa4cc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:11:40 -0800 Subject: [PATCH 134/209] config: cmd+f on macos start_search default --- src/config/Config.zig | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 753a2d697..04b2c19e3 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6403,6 +6403,14 @@ pub const Keybinds = struct { .{ .jump_to_prompt = 1 }, ); + // Search + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true } }, + .start_search, + .{ .performable = true }, + ); + // Inspector, matching Chromium try self.set.put( alloc, From b7e70ce534bc53b9cef6b1b8c10e116e2f5f447c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:13:57 -0800 Subject: [PATCH 135/209] apprt: end_search --- include/ghostty.h | 1 + macos/Sources/Ghostty/Ghostty.App.swift | 24 ++++++++++++++++++++++++ src/Surface.zig | 9 ++++++++- src/apprt/action.zig | 4 ++++ src/config/Config.zig | 6 ++++++ src/input/command.zig | 7 ++++++- 6 files changed, 49 insertions(+), 2 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 8c4455564..f90833020 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -817,6 +817,7 @@ typedef enum { GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD, GHOSTTY_ACTION_COMMAND_FINISHED, GHOSTTY_ACTION_START_SEARCH, + GHOSTTY_ACTION_END_SEARCH, } ghostty_action_tag_e; typedef union { diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 5c62e7040..8b6bf8608 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -609,6 +609,9 @@ extension Ghostty { case GHOSTTY_ACTION_START_SEARCH: startSearch(app, target: target, v: action.action.start_search) + case GHOSTTY_ACTION_END_SEARCH: + endSearch(app, target: target) + case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: fallthrough case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS: @@ -1667,6 +1670,27 @@ extension Ghostty { } } + private static func endSearch( + _ app: ghostty_app_t, + target: ghostty_target_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("end_search does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + DispatchQueue.main.async { + surfaceView.searchState = nil + } + + default: + assertionFailure() + } + } + private static func configReload( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/src/Surface.zig b/src/Surface.zig index 1e1363229..380300a84 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4892,7 +4892,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool const s: *Search = if (self.search) |*s| s else init: { // If we're stopping the search and we had no prior search, // then there is nothing to do. - if (text.len == 0) break :search; + if (text.len == 0) return false; // We need to assign directly to self.search because we need // a stable pointer back to the thread state. @@ -4922,6 +4922,13 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool if (text.len == 0) { s.deinit(); self.search = null; + + // Notify apprt search has ended. + _ = try self.rt_app.performAction( + .{ .surface = self }, + .end_search, + {}, + ); break :search; } diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 45fa8aca0..e627ce803 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -304,6 +304,9 @@ pub const Action = union(Key) { /// Start the search overlay with an optional initial needle. start_search: StartSearch, + /// End the search overlay, clearing the search state and hiding it. + end_search, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -362,6 +365,7 @@ pub const Action = union(Key) { show_on_screen_keyboard, command_finished, start_search, + end_search, }; /// Sync with: ghostty_action_u diff --git a/src/config/Config.zig b/src/config/Config.zig index 04b2c19e3..85e777349 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6410,6 +6410,12 @@ pub const Keybinds = struct { .start_search, .{ .performable = true }, ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .escape } }, + .{ .search = "" }, + .{ .performable = true }, + ); // Inspector, matching Chromium try self.set.put( diff --git a/src/input/command.zig b/src/input/command.zig index 37dc08fb4..9f1d4d3d5 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -179,6 +179,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Navigate to the previous search result, if any.", } }, + .search => comptime &.{.{ + .action = .{ .search = "" }, + .title = "End Search", + .description = "End a search if one is active.", + }}, + .increase_font_size => comptime &.{.{ .action = .{ .increase_font_size = 1 }, .title = "Increase Font Size", @@ -620,7 +626,6 @@ fn actionCommands(action: Action.Key) []const Command { .csi, .esc, .cursor_key, - .search, .set_font_size, .scroll_to_row, .scroll_page_fractional, From c61d28a3a4a964051f35a2027266842cc925e905 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:20:01 -0800 Subject: [PATCH 136/209] macos: esc returns focus back to surface --- macos/Sources/Ghostty/SurfaceView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index dabfb4a57..00eb957ec 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -200,7 +200,7 @@ extension Ghostty { // Search overlay if surfaceView.searchState != nil { - SurfaceSearchOverlay(searchState: $surfaceView.searchState) + SurfaceSearchOverlay(surfaceView: surfaceView, searchState: $surfaceView.searchState) } // Show bell border if enabled @@ -389,6 +389,7 @@ extension Ghostty { /// Search overlay view that displays a search bar with input field and navigation buttons. struct SurfaceSearchOverlay: View { + let surfaceView: SurfaceView @Binding var searchState: SurfaceView.SearchState? @State private var searchText: String = "" @State private var corner: Corner = .topRight @@ -409,6 +410,9 @@ extension Ghostty { .background(Color.primary.opacity(0.1)) .cornerRadius(6) .focused($isSearchFieldFocused) + .onExitCommand { + Ghostty.moveFocus(to: surfaceView) + } Button(action: {}) { Image(systemName: "chevron.up") From 56d4a7f58e2a5622447191b0ebc779f2db26e07d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:24:04 -0800 Subject: [PATCH 137/209] macos: start_search refocuses the search input --- macos/Sources/Ghostty/Ghostty.App.swift | 6 +++++- macos/Sources/Ghostty/Package.swift | 3 +++ macos/Sources/Ghostty/SurfaceView.swift | 4 ++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 8b6bf8608..42b146754 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -1662,7 +1662,11 @@ extension Ghostty { let startSearch = Ghostty.Action.StartSearch(c: v) DispatchQueue.main.async { - surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch) + if surfaceView.searchState != nil { + NotificationCenter.default.post(name: .ghosttySearchFocus, object: surfaceView) + } else { + surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch) + } } default: diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index f36b486ba..7ee815caa 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -396,6 +396,9 @@ extension Notification.Name { /// Notification sent when scrollbar updates static let ghosttyDidUpdateScrollbar = Notification.Name("com.mitchellh.ghostty.didUpdateScrollbar") static let ScrollbarKey = ghosttyDidUpdateScrollbar.rawValue + ".scrollbar" + + /// Focus the search field + static let ghosttySearchFocus = Notification.Name("com.mitchellh.ghostty.searchFocus") } // NOTE: I am moving all of these to Notification.Name extensions over time. This diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 00eb957ec..7cd37acb7 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -441,6 +441,10 @@ extension Ghostty { } isSearchFieldFocused = true } + .onReceive(NotificationCenter.default.publisher(for: .ghosttySearchFocus)) { notification in + guard notification.object as? SurfaceView === surfaceView else { return } + isSearchFieldFocused = true + } .background( GeometryReader { barGeo in Color.clear.onAppear { From 081d73d850f1cf679207a2a2e1efa5b96133421e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:26:52 -0800 Subject: [PATCH 138/209] macos: changes to SearchState trigger calls to internals --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 19054b6c3..50b0e8597 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -65,7 +65,17 @@ extension Ghostty { @Published var keySequence: [KeyboardShortcut] = [] // The current search state. When non-nil, the search overlay should be shown. - @Published var searchState: SearchState? = nil + @Published var searchState: SearchState? = nil { + didSet { + // If the search state becomes nil, we need to make sure we're stopping + // the search internally. + if searchState == nil { + guard let surface = self.surface else { return } + let action = "search:" + ghostty_surface_binding_action(surface, action, UInt(action.count)) + } + } + } // The time this surface last became focused. This is a ContinuousClock.Instant // on supported platforms. From 5ee000f58f957d8aa6eb1467e3a2e03aab53857b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:34:46 -0800 Subject: [PATCH 139/209] macos: search input starts the search up --- macos/Sources/Ghostty/SurfaceView.swift | 9 ++++----- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 15 ++++++++++++--- src/Surface.zig | 2 +- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 7cd37acb7..023d0475e 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -391,7 +391,6 @@ extension Ghostty { struct SurfaceSearchOverlay: View { let surfaceView: SurfaceView @Binding var searchState: SurfaceView.SearchState? - @State private var searchText: String = "" @State private var corner: Corner = .topRight @State private var dragOffset: CGSize = .zero @State private var barSize: CGSize = .zero @@ -402,7 +401,10 @@ extension Ghostty { var body: some View { GeometryReader { geo in HStack(spacing: 8) { - TextField("Search", text: $searchText) + TextField("Search", text: Binding( + get: { searchState?.needle ?? "" }, + set: { searchState?.needle = $0 } + )) .textFieldStyle(.plain) .frame(width: 180) .padding(.horizontal, 8) @@ -436,9 +438,6 @@ extension Ghostty { .cornerRadius(8) .shadow(radius: 4) .onAppear { - if let needle = searchState?.needle { - searchText = needle - } isSearchFieldFocused = true } .onReceive(NotificationCenter.default.publisher(for: .ghosttySearchFocus)) { notification in diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 50b0e8597..9cc8aa284 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import SwiftUI import CoreText import UserNotifications @@ -67,15 +68,23 @@ extension Ghostty { // The current search state. When non-nil, the search overlay should be shown. @Published var searchState: SearchState? = nil { didSet { - // If the search state becomes nil, we need to make sure we're stopping - // the search internally. - if searchState == nil { + if let searchState { + searchNeedleCancellable = searchState.$needle.sink { [weak self] needle in + guard let surface = self?.surface else { return } + let action = "search:\(needle)" + ghostty_surface_binding_action(surface, action, UInt(action.count)) + } + } else { + searchNeedleCancellable = nil guard let surface = self.surface else { return } let action = "search:" ghostty_surface_binding_action(surface, action, UInt(action.count)) } } } + + // Cancellable for search state needle changes + private var searchNeedleCancellable: AnyCancellable? // The time this surface last became focused. This is a ContinuousClock.Instant // on supported platforms. diff --git a/src/Surface.zig b/src/Surface.zig index 380300a84..2163ce0e4 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4879,7 +4879,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .start_search => if (self.search == null) { // To save resources, we don't actually start a search here, - // we just notify teh apprt. The real thread will start when + // we just notify the apprt. The real thread will start when // the first needles are set. _ = try self.rt_app.performAction( .{ .surface = self }, From ad8a6e0642da4770d2e058ec7f3bd931f519c15b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:43:23 -0800 Subject: [PATCH 140/209] search thread needs to take an allocated needle --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 1 + src/Surface.zig | 5 +- src/apprt/surface.zig | 4 +- src/datastruct/main.zig | 1 + src/datastruct/message_data.zig | 124 ++++++++++++++++++ src/terminal/search/Thread.zig | 17 ++- src/termio.zig | 1 - src/termio/message.zig | 122 +---------------- 8 files changed, 147 insertions(+), 128 deletions(-) create mode 100644 src/datastruct/message_data.zig diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 9cc8aa284..d4cf61b69 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -71,6 +71,7 @@ extension Ghostty { if let searchState { searchNeedleCancellable = searchState.$needle.sink { [weak self] needle in guard let surface = self?.surface else { return } + guard needle.count > 1 else { return } let action = "search:\(needle)" ghostty_surface_binding_action(surface, action, UInt(action.count)) } diff --git a/src/Surface.zig b/src/Surface.zig index 2163ce0e4..3f6884997 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4933,7 +4933,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool } _ = s.state.mailbox.push( - .{ .change_needle = text }, + .{ .change_needle = try .init( + self.alloc, + text, + ) }, .forever, ); s.state.wakeup.notify() catch {}; diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index b71bf1e6e..9e44a35d0 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -6,15 +6,15 @@ const build_config = @import("../build_config.zig"); const App = @import("../App.zig"); const Surface = @import("../Surface.zig"); const renderer = @import("../renderer.zig"); -const termio = @import("../termio.zig"); const terminal = @import("../terminal/main.zig"); const Config = @import("../config.zig").Config; +const MessageData = @import("../datastruct/main.zig").MessageData; /// The message types that can be sent to a single surface. pub const Message = union(enum) { /// Represents a write request. Magic number comes from the max size /// we want this union to be. - pub const WriteReq = termio.MessageData(u8, 255); + pub const WriteReq = MessageData(u8, 255); /// Set the title of the surface. /// TODO: we should change this to a "WriteReq" style structure in diff --git a/src/datastruct/main.zig b/src/datastruct/main.zig index 14ee0e504..64a29269e 100644 --- a/src/datastruct/main.zig +++ b/src/datastruct/main.zig @@ -13,6 +13,7 @@ pub const BlockingQueue = blocking_queue.BlockingQueue; pub const CacheTable = cache_table.CacheTable; pub const CircBuf = circ_buf.CircBuf; pub const IntrusiveDoublyLinkedList = intrusive_linked_list.DoublyLinkedList; +pub const MessageData = @import("message_data.zig").MessageData; pub const SegmentedPool = segmented_pool.SegmentedPool; pub const SplitTree = split_tree.SplitTree; diff --git a/src/datastruct/message_data.zig b/src/datastruct/message_data.zig new file mode 100644 index 000000000..3e5cdae66 --- /dev/null +++ b/src/datastruct/message_data.zig @@ -0,0 +1,124 @@ +const std = @import("std"); +const assert = @import("../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; + +/// Creates a union that can be used to accommodate data that fit within an array, +/// are a stable pointer, or require deallocation. This is helpful for thread +/// messaging utilities. +pub fn MessageData(comptime Elem: type, comptime small_size: comptime_int) type { + return union(enum) { + pub const Self = @This(); + + pub const Small = struct { + pub const Max = small_size; + pub const Array = [Max]Elem; + pub const Len = std.math.IntFittingRange(0, small_size); + data: Array = undefined, + len: Len = 0, + }; + + pub const Alloc = struct { + alloc: Allocator, + data: []Elem, + }; + + pub const Stable = []const Elem; + + /// A small write where the data fits into this union size. + small: Small, + + /// A stable pointer so we can just pass the slice directly through. + /// This is useful i.e. for const data. + stable: Stable, + + /// Allocated and must be freed with the provided allocator. This + /// should be rarely used. + alloc: Alloc, + + /// Initializes the union for a given data type. This will + /// attempt to fit into a small value if possible, otherwise + /// will allocate and put into alloc. + /// + /// This can't and will never detect stable pointers. + pub fn init(alloc: Allocator, data: anytype) !Self { + switch (@typeInfo(@TypeOf(data))) { + .pointer => |info| { + assert(info.size == .slice); + assert(info.child == Elem); + + // If it fits in our small request, do that. + if (data.len <= Small.Max) { + var buf: Small.Array = undefined; + @memcpy(buf[0..data.len], data); + return Self{ + .small = .{ + .data = buf, + .len = @intCast(data.len), + }, + }; + } + + // Otherwise, allocate + const buf = try alloc.dupe(Elem, data); + errdefer alloc.free(buf); + return Self{ + .alloc = .{ + .alloc = alloc, + .data = buf, + }, + }; + }, + + else => unreachable, + } + } + + pub fn deinit(self: Self) void { + switch (self) { + .small, .stable => {}, + .alloc => |v| v.alloc.free(v.data), + } + } + + /// Returns a const slice of the data pointed to by this request. + pub fn slice(self: *const Self) []const Elem { + return switch (self.*) { + .small => |*v| v.data[0..v.len], + .stable => |v| v, + .alloc => |v| v.data, + }; + } + }; +} + +test "MessageData init small" { + const testing = std.testing; + const alloc = testing.allocator; + + const Data = MessageData(u8, 10); + const input = "hello!"; + const io = try Data.init(alloc, @as([]const u8, input)); + try testing.expect(io == .small); +} + +test "MessageData init alloc" { + const testing = std.testing; + const alloc = testing.allocator; + + const Data = MessageData(u8, 10); + const input = "hello! " ** 100; + const io = try Data.init(alloc, @as([]const u8, input)); + try testing.expect(io == .alloc); + io.alloc.alloc.free(io.alloc.data); +} + +test "MessageData small fits non-u8 sized data" { + const testing = std.testing; + const alloc = testing.allocator; + + const len = 500; + const Data = MessageData(u8, len); + const input: []const u8 = "X" ** len; + const io = try Data.init(alloc, input); + try testing.expect(io == .small); +} diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index e6094b8e5..f76af29fd 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -17,6 +17,7 @@ const Mutex = std.Thread.Mutex; const xev = @import("../../global.zig").xev; const internal_os = @import("../../os/main.zig"); const BlockingQueue = @import("../../datastruct/main.zig").BlockingQueue; +const MessageData = @import("../../datastruct/main.zig").MessageData; const point = @import("../point.zig"); const FlattenedHighlight = @import("../highlight.zig").Flattened; const UntrackedHighlight = @import("../highlight.zig").Untracked; @@ -242,7 +243,10 @@ fn drainMailbox(self: *Thread) !void { while (self.mailbox.pop()) |message| { log.debug("mailbox message={}", .{message}); switch (message) { - .change_needle => |v| try self.changeNeedle(v), + .change_needle => |v| { + defer v.deinit(); + try self.changeNeedle(v.slice()); + }, .select => |v| try self.select(v), } } @@ -414,10 +418,14 @@ pub const Mailbox = BlockingQueue(Message, 64); /// The messages that can be sent to the thread. pub const Message = union(enum) { + /// Represents a write request. Magic number comes from the max size + /// we want this union to be. + pub const WriteReq = MessageData(u8, 255); + /// Change the search term. If no prior search term is given this /// will start a search. If an existing search term is given this will /// stop the prior search and start a new one. - change_needle: []const u8, + change_needle: WriteReq, /// Select a search result. select: ScreenSearch.Select, @@ -820,7 +828,10 @@ test { // Start our search _ = thread.mailbox.push( - .{ .change_needle = "world" }, + .{ .change_needle = try .init( + alloc, + @as([]const u8, "world"), + ) }, .forever, ); try thread.wakeup.notify(); diff --git a/src/termio.zig b/src/termio.zig index c69785b25..b16885109 100644 --- a/src/termio.zig +++ b/src/termio.zig @@ -30,7 +30,6 @@ pub const Backend = backend.Backend; pub const DerivedConfig = Termio.DerivedConfig; pub const Mailbox = mailbox.Mailbox; pub const Message = message.Message; -pub const MessageData = message.MessageData; pub const StreamHandler = stream_handler.StreamHandler; test { diff --git a/src/termio/message.zig b/src/termio/message.zig index de7ea16cb..23b9f2545 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -5,6 +5,7 @@ const apprt = @import("../apprt.zig"); const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); const termio = @import("../termio.zig"); +const MessageData = @import("../datastruct/main.zig").MessageData; /// The messages that can be sent to an IO thread. /// @@ -97,95 +98,6 @@ pub const Message = union(enum) { }; }; -/// Creates a union that can be used to accommodate data that fit within an array, -/// are a stable pointer, or require deallocation. This is helpful for thread -/// messaging utilities. -pub fn MessageData(comptime Elem: type, comptime small_size: comptime_int) type { - return union(enum) { - pub const Self = @This(); - - pub const Small = struct { - pub const Max = small_size; - pub const Array = [Max]Elem; - pub const Len = std.math.IntFittingRange(0, small_size); - data: Array = undefined, - len: Len = 0, - }; - - pub const Alloc = struct { - alloc: Allocator, - data: []Elem, - }; - - pub const Stable = []const Elem; - - /// A small write where the data fits into this union size. - small: Small, - - /// A stable pointer so we can just pass the slice directly through. - /// This is useful i.e. for const data. - stable: Stable, - - /// Allocated and must be freed with the provided allocator. This - /// should be rarely used. - alloc: Alloc, - - /// Initializes the union for a given data type. This will - /// attempt to fit into a small value if possible, otherwise - /// will allocate and put into alloc. - /// - /// This can't and will never detect stable pointers. - pub fn init(alloc: Allocator, data: anytype) !Self { - switch (@typeInfo(@TypeOf(data))) { - .pointer => |info| { - assert(info.size == .slice); - assert(info.child == Elem); - - // If it fits in our small request, do that. - if (data.len <= Small.Max) { - var buf: Small.Array = undefined; - @memcpy(buf[0..data.len], data); - return Self{ - .small = .{ - .data = buf, - .len = @intCast(data.len), - }, - }; - } - - // Otherwise, allocate - const buf = try alloc.dupe(Elem, data); - errdefer alloc.free(buf); - return Self{ - .alloc = .{ - .alloc = alloc, - .data = buf, - }, - }; - }, - - else => unreachable, - } - } - - pub fn deinit(self: Self) void { - switch (self) { - .small, .stable => {}, - .alloc => |v| v.alloc.free(v.data), - } - } - - /// Returns a const slice of the data pointed to by this request. - pub fn slice(self: *const Self) []const Elem { - return switch (self.*) { - .small => |*v| v.data[0..v.len], - .stable => |v| v, - .alloc => |v| v.data, - }; - } - }; -} - test { std.testing.refAllDecls(@This()); } @@ -195,35 +107,3 @@ test { const testing = std.testing; try testing.expectEqual(@as(usize, 40), @sizeOf(Message)); } - -test "MessageData init small" { - const testing = std.testing; - const alloc = testing.allocator; - - const Data = MessageData(u8, 10); - const input = "hello!"; - const io = try Data.init(alloc, @as([]const u8, input)); - try testing.expect(io == .small); -} - -test "MessageData init alloc" { - const testing = std.testing; - const alloc = testing.allocator; - - const Data = MessageData(u8, 10); - const input = "hello! " ** 100; - const io = try Data.init(alloc, @as([]const u8, input)); - try testing.expect(io == .alloc); - io.alloc.alloc.free(io.alloc.data); -} - -test "MessageData small fits non-u8 sized data" { - const testing = std.testing; - const alloc = testing.allocator; - - const len = 500; - const Data = MessageData(u8, len); - const input: []const u8 = "X" ** len; - const io = try Data.init(alloc, input); - try testing.expect(io == .small); -} From 15f00a9cd1368642a14bc105c76831f720b27286 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 15:29:24 -0800 Subject: [PATCH 141/209] renderer: setup proper dirty state on search selection changing --- src/Surface.zig | 4 ++++ src/renderer/generic.zig | 19 ++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 3f6884997..55a96c02e 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1391,6 +1391,10 @@ fn searchCallback_( // When we quit, tell our renderer to reset any search state. .quit => { + _ = self.renderer_thread.mailbox.push( + .{ .search_selected_match = null }, + .forever, + ); _ = self.renderer_thread.mailbox.push( .{ .search_viewport_matches = .{ .arena = .init(self.alloc), diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index bddda7ef0..df36c4a7e 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1217,8 +1217,21 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (self.search_matches_dirty or self.terminal_state.dirty != .false) { self.search_matches_dirty = false; - for (self.terminal_state.row_data.items(.highlights)) |*highlights| { - highlights.clearRetainingCapacity(); + // Clear the prior highlights + const row_data = self.terminal_state.row_data.slice(); + var any_dirty: bool = false; + for ( + row_data.items(.highlights), + row_data.items(.dirty), + ) |*highlights, *dirty| { + if (highlights.items.len > 0) { + highlights.clearRetainingCapacity(); + dirty.* = true; + any_dirty = true; + } + } + if (any_dirty and self.terminal_state.dirty == .false) { + self.terminal_state.dirty = .partial; } // NOTE: The order below matters. Highlights added earlier @@ -1228,7 +1241,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.terminal_state.updateHighlightsFlattened( self.alloc, @intFromEnum(HighlightTag.search_match_selected), - (&m.match)[0..1], + &.{m.match}, ) catch |err| { // Not a critical error, we just won't show highlights. log.warn("error updating search selected highlight err={}", .{err}); From 3ce19a02ba5afa560e06aa0b4b0b22c50f05800f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 15:33:33 -0800 Subject: [PATCH 142/209] macos: hook up the next/prev search buttons --- macos/Sources/Ghostty/SurfaceView.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 023d0475e..d8fc68a47 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -416,12 +416,20 @@ extension Ghostty { Ghostty.moveFocus(to: surfaceView) } - Button(action: {}) { + Button(action: { + guard let surface = surfaceView.surface else { return } + let action = "navigate_search:next" + ghostty_surface_binding_action(surface, action, UInt(action.count)) + }) { Image(systemName: "chevron.up") } .buttonStyle(.borderless) - Button(action: {}) { + Button(action: { + guard let surface = surfaceView.surface else { return } + let action = "navigate_search:previous" + ghostty_surface_binding_action(surface, action, UInt(action.count)) + }) { Image(systemName: "chevron.down") } .buttonStyle(.borderless) From 72708b8253227a9c835599596f81ae793a40bc08 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 15:40:44 -0800 Subject: [PATCH 143/209] search: do not restart search if needle doesn't change --- src/terminal/search/Thread.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index f76af29fd..275af6d93 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -279,6 +279,9 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void { // Stop the previous search if (self.search) |*s| { + // If our search is unchanged, do nothing. + if (std.ascii.eqlIgnoreCase(s.viewport.needle(), needle)) return; + s.deinit(); self.search = null; From efc05523e051b992014e91db3ef4aa4e887f3839 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 17:21:04 -0800 Subject: [PATCH 144/209] macos: enter goes to next result --- macos/Sources/Ghostty/SurfaceView.swift | 5 +++++ macos/Sources/Ghostty/SurfaceView_AppKit.swift | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index d8fc68a47..6a0f369c9 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -412,6 +412,11 @@ extension Ghostty { .background(Color.primary.opacity(0.1)) .cornerRadius(6) .focused($isSearchFieldFocused) + .onSubmit { + guard let surface = surfaceView.surface else { return } + let action = "navigate_search:next" + ghostty_surface_binding_action(surface, action, UInt(action.count)) + } .onExitCommand { Ghostty.moveFocus(to: surfaceView) } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index d4cf61b69..e67a85349 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -69,7 +69,7 @@ extension Ghostty { @Published var searchState: SearchState? = nil { didSet { if let searchState { - searchNeedleCancellable = searchState.$needle.sink { [weak self] needle in + searchNeedleCancellable = searchState.$needle.removeDuplicates().sink { [weak self] needle in guard let surface = self?.surface else { return } guard needle.count > 1 else { return } let action = "search:\(needle)" From cfbc219f5c8f5ccff2215b3e3716bb343eace181 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 17:25:01 -0800 Subject: [PATCH 145/209] macos: enter and shift+enter move the results --- macos/Sources/Ghostty/SurfaceView.swift | 35 ++++++++++++++----------- macos/Sources/Helpers/Backport.swift | 24 +++++++++++++++++ 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 6a0f369c9..6d0cc21be 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -405,21 +405,24 @@ extension Ghostty { get: { searchState?.needle ?? "" }, set: { searchState?.needle = $0 } )) - .textFieldStyle(.plain) - .frame(width: 180) - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background(Color.primary.opacity(0.1)) - .cornerRadius(6) - .focused($isSearchFieldFocused) - .onSubmit { - guard let surface = surfaceView.surface else { return } - let action = "navigate_search:next" - ghostty_surface_binding_action(surface, action, UInt(action.count)) - } - .onExitCommand { - Ghostty.moveFocus(to: surfaceView) - } + .textFieldStyle(.plain) + .frame(width: 180) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color.primary.opacity(0.1)) + .cornerRadius(6) + .focused($isSearchFieldFocused) + .onExitCommand { + Ghostty.moveFocus(to: surfaceView) + } + .backport.onKeyPress(.return) { modifiers in + guard let surface = surfaceView.surface else { return .ignored } + let action = modifiers.contains(.shift) + ? "navigate_search:previous" + : "navigate_search:next" + ghostty_surface_binding_action(surface, action, UInt(action.count)) + return .handled + } Button(action: { guard let surface = surfaceView.surface else { return } @@ -526,7 +529,7 @@ extension Ghostty { } } } - + /// A surface is terminology in Ghostty for a terminal surface, or a place where a terminal is actually drawn /// and interacted with. The word "surface" is used because a surface may represent a window, a tab, /// a split, a small preview pane, etc. It is ANYTHING that has a terminal drawn to it. diff --git a/macos/Sources/Helpers/Backport.swift b/macos/Sources/Helpers/Backport.swift index a28be15ae..8c43652e4 100644 --- a/macos/Sources/Helpers/Backport.swift +++ b/macos/Sources/Helpers/Backport.swift @@ -18,6 +18,12 @@ extension Backport where Content: Scene { // None currently } +/// Result type for backported onKeyPress handler +enum BackportKeyPressResult { + case handled + case ignored +} + extension Backport where Content: View { func pointerVisibility(_ v: BackportVisibility) -> some View { #if canImport(AppKit) @@ -42,6 +48,24 @@ extension Backport where Content: View { return content #endif } + + /// Backported onKeyPress that works on macOS 14+ and is a no-op on macOS 13. + func onKeyPress(_ key: KeyEquivalent, action: @escaping (EventModifiers) -> BackportKeyPressResult) -> some View { + #if canImport(AppKit) + if #available(macOS 14, *) { + return content.onKeyPress(key, phases: .down, action: { keyPress in + switch action(keyPress.modifiers) { + case .handled: return .handled + case .ignored: return .ignored + } + }) + } else { + return content + } + #else + return content + #endif + } } enum BackportVisibility { From 5b2d66e26186b58b1a84a4b76a8dfe9a10bc8400 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 17:34:58 -0800 Subject: [PATCH 146/209] apprt/gtk: disable search apprt actions --- src/apprt/gtk/class/application.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index eac88f9cf..05c6adc2b 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -743,6 +743,8 @@ pub const Application = extern struct { .check_for_updates, .undo, .redo, + .start_search, + .end_search, => { log.warn("unimplemented action={}", .{action}); return false; From 949a8ea53fbf5b319743cd378e73b5dc58623877 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:05:48 -0800 Subject: [PATCH 147/209] macos: dummy search state for iOS --- macos/Sources/Ghostty/SurfaceView.swift | 2 ++ macos/Sources/Ghostty/SurfaceView_UIKit.swift | 3 +++ 2 files changed, 5 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 6d0cc21be..1718aeead 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -413,7 +413,9 @@ extension Ghostty { .cornerRadius(6) .focused($isSearchFieldFocused) .onExitCommand { + #if canImport(AppKit) Ghostty.moveFocus(to: surfaceView) + #endif } .backport.onKeyPress(.return) { modifiers in guard let surface = surfaceView.surface else { return .ignored } diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index 29364d4a5..09c41c0b5 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -40,6 +40,9 @@ extension Ghostty { /// True when the bell is active. This is set inactive on focus or event. @Published var bell: Bool = false + + // The current search state. When non-nil, the search overlay should be shown. + @Published var searchState: SearchState? = nil // Returns sizing information for the surface. This is the raw C // structure because I'm lazy. From 3f7cfca4b467ff04a3a71ea91f2d95088033ee55 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:20:11 -0800 Subject: [PATCH 148/209] macos: add find menu item --- macos/Sources/App/macOS/AppDelegate.swift | 8 +++++ macos/Sources/App/macOS/MainMenu.xib | 34 +++++++++++++++++-- .../Terminal/BaseTerminalController.swift | 12 +++++++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 24 +++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index b05351bfd..763a387ed 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -44,6 +44,10 @@ class AppDelegate: NSObject, @IBOutlet private var menuPaste: NSMenuItem? @IBOutlet private var menuPasteSelection: NSMenuItem? @IBOutlet private var menuSelectAll: NSMenuItem? + @IBOutlet private var menuFindParent: NSMenuItem? + @IBOutlet private var menuFind: NSMenuItem? + @IBOutlet private var menuFindNext: NSMenuItem? + @IBOutlet private var menuFindPrevious: NSMenuItem? @IBOutlet private var menuToggleVisibility: NSMenuItem? @IBOutlet private var menuToggleFullScreen: NSMenuItem? @@ -553,6 +557,7 @@ class AppDelegate: NSObject, self.menuMoveSplitDividerLeft?.setImageIfDesired(systemSymbolName: "arrow.left.to.line") self.menuMoveSplitDividerRight?.setImageIfDesired(systemSymbolName: "arrow.right.to.line") self.menuFloatOnTop?.setImageIfDesired(systemSymbolName: "square.filled.on.square") + self.menuFindParent?.setImageIfDesired(systemSymbolName: "text.page.badge.magnifyingglass") } /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration. @@ -581,6 +586,9 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection) syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll) + syncMenuShortcut(config, action: "start_search", menuItem: self.menuFind) + syncMenuShortcut(config, action: "search:next", menuItem: self.menuFindNext) + syncMenuShortcut(config, action: "search:previous", menuItem: self.menuFindPrevious) syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit) diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index c97ed7c61..ce6f5a0cb 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -26,6 +26,10 @@ + + + + @@ -245,6 +249,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 552f864ee..d0cea43f5 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1112,6 +1112,18 @@ class BaseTerminalController: NSWindowController, @IBAction func toggleCommandPalette(_ sender: Any?) { commandPaletteIsShowing.toggle() } + + @IBAction func find(_ sender: Any) { + focusedSurface?.find(sender) + } + + @IBAction func findNext(_ sender: Any) { + focusedSurface?.findNext(sender) + } + + @IBAction func findPrevious(_ sender: Any) { + focusedSurface?.findNext(sender) + } @objc func resetTerminal(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index e67a85349..d70cc9654 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1470,6 +1470,30 @@ extension Ghostty { AppDelegate.logger.warning("action failed action=\(action)") } } + + @IBAction func find(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "start_search" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + + @IBAction func findNext(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "search:next" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + + @IBAction func findPrevious(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "search:previous" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } @IBAction func splitRight(_ sender: Any) { guard let surface = self.surface else { return } From 240d5e0fc56d1b24fa9795335a3e38365190661a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:29:54 -0800 Subject: [PATCH 149/209] config: default search keybindings for macos --- src/Surface.zig | 10 +++++++++- src/config/Config.zig | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/Surface.zig b/src/Surface.zig index 55a96c02e..0e91b4083 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4896,7 +4896,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool const s: *Search = if (self.search) |*s| s else init: { // If we're stopping the search and we had no prior search, // then there is nothing to do. - if (text.len == 0) return false; + if (text.len == 0) { + // So GUIs can hide visible search widgets. + _ = try self.rt_app.performAction( + .{ .surface = self }, + .end_search, + {}, + ); + return false; + } // We need to assign directly to self.search because we need // a stable pointer back to the thread state. diff --git a/src/config/Config.zig b/src/config/Config.zig index 85e777349..e34666ecb 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6410,12 +6410,30 @@ pub const Keybinds = struct { .start_search, .{ .performable = true }, ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true, .shift = true } }, + .{ .search = "" }, + .{ .performable = true }, + ); try self.set.putFlags( alloc, .{ .key = .{ .physical = .escape } }, .{ .search = "" }, .{ .performable = true }, ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'g' }, .mods = .{ .super = true } }, + .{ .navigate_search = .next }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'g' }, .mods = .{ .super = true, .shift = true } }, + .{ .navigate_search = .previous }, + .{ .performable = true }, + ); // Inspector, matching Chromium try self.set.put( From 7835ad0ea43cc90c711517b43a107477b86a70f8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:34:38 -0800 Subject: [PATCH 150/209] macos: more menu items --- macos/Sources/App/macOS/AppDelegate.swift | 1 + macos/Sources/App/macOS/MainMenu.xib | 8 ++++++++ .../Terminal/BaseTerminalController.swift | 16 ++++++++++++++++ .../Features/Terminal/TerminalController.swift | 6 +++--- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 11 +++++++++++ 5 files changed, 39 insertions(+), 3 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 763a387ed..da20c2124 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -48,6 +48,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuFind: NSMenuItem? @IBOutlet private var menuFindNext: NSMenuItem? @IBOutlet private var menuFindPrevious: NSMenuItem? + @IBOutlet private var menuHideFindBar: NSMenuItem? @IBOutlet private var menuToggleVisibility: NSMenuItem? @IBOutlet private var menuToggleFullScreen: NSMenuItem? diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index ce6f5a0cb..3e1084cd7 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -31,6 +31,7 @@ + @@ -271,6 +272,13 @@ + + + + + + + diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index d0cea43f5..9104e61ff 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1124,6 +1124,10 @@ class BaseTerminalController: NSWindowController, @IBAction func findPrevious(_ sender: Any) { focusedSurface?.findNext(sender) } + + @IBAction func findHide(_ sender: Any) { + focusedSurface?.findHide(sender) + } @objc func resetTerminal(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } @@ -1148,3 +1152,15 @@ class BaseTerminalController: NSWindowController, } } } + +extension BaseTerminalController: NSMenuItemValidation { + func validateMenuItem(_ item: NSMenuItem) -> Bool { + switch item.action { + case #selector(findHide): + return focusedSurface?.searchState != nil + + default: + return true + } + } +} diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 4de0336ce..e1a98e598 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -1403,8 +1403,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // MARK: NSMenuItemValidation -extension TerminalController: NSMenuItemValidation { - func validateMenuItem(_ item: NSMenuItem) -> Bool { +extension TerminalController { + override func validateMenuItem(_ item: NSMenuItem) -> Bool { switch item.action { case #selector(returnToDefaultSize): guard let window else { return false } @@ -1433,7 +1433,7 @@ extension TerminalController: NSMenuItemValidation { return true default: - return true + return super.validateMenuItem(item) } } } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index d70cc9654..f431fdf6d 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1494,6 +1494,14 @@ extension Ghostty { AppDelegate.logger.warning("action failed action=\(action)") } } + + @IBAction func findHide(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "search:" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } @IBAction func splitRight(_ sender: Any) { guard let surface = self.surface else { return } @@ -1967,6 +1975,9 @@ extension Ghostty.SurfaceView: NSMenuItemValidation { let pb = NSPasteboard.ghosttySelection guard let str = pb.getOpinionatedStringContents() else { return false } return !str.isEmpty + + case #selector(findHide): + return searchState != nil default: return true From d4a2f3db716cc5dc738789098835c528572f0cc3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:38:14 -0800 Subject: [PATCH 151/209] macos: search overlay shows search progress --- macos/Sources/Ghostty/SurfaceView.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 1718aeead..47532c96a 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -426,6 +426,14 @@ extension Ghostty { return .handled } + if let selected = searchState?.selected { + let totalText = searchState?.total.map { String($0) } ?? "?" + Text("\(selected)/\(totalText)") + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + } + Button(action: { guard let surface = surfaceView.surface else { return } let action = "navigate_search:next" @@ -814,6 +822,8 @@ extension FocusedValues { extension Ghostty.SurfaceView { class SearchState: ObservableObject { @Published var needle: String = "" + @Published var selected: UInt? = nil + @Published var total: UInt? = nil init(from startSearch: Ghostty.Action.StartSearch) { self.needle = startSearch.needle ?? "" From 2ee2d000f5e3400d728c99821cb2c236192b85d2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:42:55 -0800 Subject: [PATCH 152/209] apprt actions for search progress --- include/ghostty.h | 14 +++++++++++ src/apprt/action.zig | 38 +++++++++++++++++++++++++++++ src/apprt/gtk/class/application.zig | 2 ++ 3 files changed, 54 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index f90833020..6cafe8773 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -752,6 +752,16 @@ typedef struct { const char* needle; } ghostty_action_start_search_s; +// apprt.action.SearchTotal +typedef struct { + ssize_t total; +} ghostty_action_search_total_s; + +// apprt.action.SearchSelected +typedef struct { + ssize_t selected; +} ghostty_action_search_selected_s; + // terminal.Scrollbar typedef struct { uint64_t total; @@ -818,6 +828,8 @@ typedef enum { GHOSTTY_ACTION_COMMAND_FINISHED, GHOSTTY_ACTION_START_SEARCH, GHOSTTY_ACTION_END_SEARCH, + GHOSTTY_ACTION_SEARCH_TOTAL, + GHOSTTY_ACTION_SEARCH_SELECTED, } ghostty_action_tag_e; typedef union { @@ -852,6 +864,8 @@ typedef union { ghostty_action_progress_report_s progress_report; ghostty_action_command_finished_s command_finished; ghostty_action_start_search_s start_search; + ghostty_action_search_total_s search_total; + ghostty_action_search_selected_s search_selected; } ghostty_action_u; typedef struct { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index e627ce803..00bf8685a 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -307,6 +307,12 @@ pub const Action = union(Key) { /// End the search overlay, clearing the search state and hiding it. end_search, + /// The total number of matches found by the search. + search_total: SearchTotal, + + /// The currently selected search match index (1-based). + search_selected: SearchSelected, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -366,6 +372,8 @@ pub const Action = union(Key) { command_finished, start_search, end_search, + search_total, + search_selected, }; /// Sync with: ghostty_action_u @@ -793,3 +801,33 @@ pub const StartSearch = struct { }; } }; + +pub const SearchTotal = struct { + total: ?usize, + + // Sync with: ghostty_action_search_total_s + pub const C = extern struct { + total: isize, + }; + + pub fn cval(self: SearchTotal) C { + return .{ + .total = if (self.total) |t| @intCast(t) else -1, + }; + } +}; + +pub const SearchSelected = struct { + selected: ?usize, + + // Sync with: ghostty_action_search_selected_s + pub const C = extern struct { + selected: isize, + }; + + pub fn cval(self: SearchSelected) C { + return .{ + .selected = if (self.selected) |s| @intCast(s) else -1, + }; + } +}; diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 05c6adc2b..9c22782c7 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -745,6 +745,8 @@ pub const Application = extern struct { .redo, .start_search, .end_search, + .search_total, + .search_selected, => { log.warn("unimplemented action={}", .{action}); return false; From c20af77f98b2a33b8e151ef1dd7d7074f188fa90 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:44:19 -0800 Subject: [PATCH 153/209] macos: handle search progress/total apprt actions --- macos/Sources/Ghostty/Ghostty.App.swift | 52 +++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 42b146754..9c1acd1a8 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -612,6 +612,12 @@ extension Ghostty { case GHOSTTY_ACTION_END_SEARCH: endSearch(app, target: target) + case GHOSTTY_ACTION_SEARCH_TOTAL: + searchTotal(app, target: target, v: action.action.search_total) + + case GHOSTTY_ACTION_SEARCH_SELECTED: + searchSelected(app, target: target, v: action.action.search_selected) + case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: fallthrough case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS: @@ -1695,6 +1701,52 @@ extension Ghostty { } } + private static func searchTotal( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_search_total_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("search_total does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + let total: UInt? = v.total >= 0 ? UInt(v.total) : nil + DispatchQueue.main.async { + surfaceView.searchState?.total = total + } + + default: + assertionFailure() + } + } + + private static func searchSelected( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_search_selected_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("search_selected does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + let selected: UInt? = v.selected >= 0 ? UInt(v.selected) : nil + DispatchQueue.main.async { + surfaceView.searchState?.selected = selected + } + + default: + assertionFailure() + } + } + private static func configReload( _ app: ghostty_app_t, target: ghostty_target_s, From 7320b234b48c7c078840a12813b5ff4261fde41b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:47:06 -0800 Subject: [PATCH 154/209] core: surface sends search total/progress to apprt --- src/Surface.zig | 57 ++++++++++++++++++++++++++++++++++++++++--- src/apprt/surface.zig | 6 +++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 0e91b4083..87cbd05b9 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -804,6 +804,14 @@ pub fn close(self: *Surface) void { self.rt_surface.close(self.needsConfirmQuit()); } +/// Returns a mailbox that can be used to send messages to this surface. +inline fn surfaceMailbox(self: *Surface) Mailbox { + return .{ + .surface = self, + .app = .{ .rt_app = self.rt_app, .mailbox = &self.app.mailbox }, + }; +} + /// Forces the surface to render. This is useful for when the surface /// is in the middle of animation (such as a resize, etc.) or when /// the render timer is managed manually by the apprt. @@ -1069,6 +1077,22 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { log.warn("apprt failed to notify command finish={}", .{err}); }; }, + + .search_total => |v| { + _ = try self.rt_app.performAction( + .{ .surface = self }, + .search_total, + .{ .total = v }, + ); + }, + + .search_selected => |v| { + _ = try self.rt_app.performAction( + .{ .surface = self }, + .search_selected, + .{ .selected = v }, + ); + }, } } @@ -1378,17 +1402,36 @@ fn searchCallback_( } }, .forever, ); + + // Send the selected index to the surface mailbox + _ = self.surfaceMailbox().push( + .{ .search_selected = sel.idx }, + .forever, + ); } else { // Reset our selected match _ = self.renderer_thread.mailbox.push( .{ .search_selected_match = null }, .forever, ); + + // Reset the selected index + _ = self.surfaceMailbox().push( + .{ .search_selected = null }, + .forever, + ); } try self.renderer_thread.wakeup.notify(); }, + .total_matches => |total| { + _ = self.surfaceMailbox().push( + .{ .search_total = total }, + .forever, + ); + }, + // When we quit, tell our renderer to reset any search state. .quit => { _ = self.renderer_thread.mailbox.push( @@ -1403,12 +1446,20 @@ fn searchCallback_( .forever, ); try self.renderer_thread.wakeup.notify(); + + // Reset search totals in the surface + _ = self.surfaceMailbox().push( + .{ .search_total = null }, + .forever, + ); + _ = self.surfaceMailbox().push( + .{ .search_selected = null }, + .forever, + ); }, // Unhandled, so far. - .total_matches, - .complete, - => {}, + .complete => {}, } } diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 9e44a35d0..45a847493 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -107,6 +107,12 @@ pub const Message = union(enum) { /// The scrollbar state changed for the surface. scrollbar: terminal.Scrollbar, + /// Search progress update + search_total: ?usize, + + /// Selected search index change + search_selected: ?usize, + pub const ReportTitleStyle = enum { csi_21_t, From 0e974f85edfdc3fe60e28aa7b0539ddea3f22ee5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:52:26 -0800 Subject: [PATCH 155/209] macos: fix iOS build --- macos/Sources/Ghostty/SurfaceView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 47532c96a..4c9fecaee 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -412,11 +412,11 @@ extension Ghostty { .background(Color.primary.opacity(0.1)) .cornerRadius(6) .focused($isSearchFieldFocused) +#if canImport(AppKit) .onExitCommand { - #if canImport(AppKit) Ghostty.moveFocus(to: surfaceView) - #endif } +#endif .backport.onKeyPress(.return) { modifiers in guard let surface = surfaceView.surface else { return .ignored } let action = modifiers.contains(.shift) From 93656fca5abe7e24ef5c413cdc3c69269fad6acb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:58:33 -0800 Subject: [PATCH 156/209] macos: show progerss correctly for search --- macos/Sources/Ghostty/SurfaceView.swift | 30 ++++++++++++++----------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 4c9fecaee..6d17258d8 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -199,8 +199,12 @@ extension Ghostty { #endif // Search overlay - if surfaceView.searchState != nil { - SurfaceSearchOverlay(surfaceView: surfaceView, searchState: $surfaceView.searchState) + if let searchState = surfaceView.searchState { + SurfaceSearchOverlay( + surfaceView: surfaceView, + searchState: searchState, + onClose: { surfaceView.searchState = nil } + ) } // Show bell border if enabled @@ -390,7 +394,8 @@ extension Ghostty { /// Search overlay view that displays a search bar with input field and navigation buttons. struct SurfaceSearchOverlay: View { let surfaceView: SurfaceView - @Binding var searchState: SurfaceView.SearchState? + @ObservedObject var searchState: SurfaceView.SearchState + let onClose: () -> Void @State private var corner: Corner = .topRight @State private var dragOffset: CGSize = .zero @State private var barSize: CGSize = .zero @@ -401,10 +406,7 @@ extension Ghostty { var body: some View { GeometryReader { geo in HStack(spacing: 8) { - TextField("Search", text: Binding( - get: { searchState?.needle ?? "" }, - set: { searchState?.needle = $0 } - )) + TextField("Search", text: $searchState.needle) .textFieldStyle(.plain) .frame(width: 180) .padding(.horizontal, 8) @@ -426,9 +428,13 @@ extension Ghostty { return .handled } - if let selected = searchState?.selected { - let totalText = searchState?.total.map { String($0) } ?? "?" - Text("\(selected)/\(totalText)") + if let selected = searchState.selected { + Text("\(selected + 1)/\(searchState.total, default: "?")") + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + } else if let total = searchState.total { + Text("-/\(total)") .font(.caption) .foregroundColor(.secondary) .monospacedDigit() @@ -452,9 +458,7 @@ extension Ghostty { } .buttonStyle(.borderless) - Button(action: { - searchState = nil - }) { + Button(action: onClose) { Image(systemName: "xmark") } .buttonStyle(.borderless) From 48acc90983afb25ef81f07aface3d31c6add6753 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 21:40:16 -0800 Subject: [PATCH 157/209] terminal: search should reload active area if dirty --- src/terminal/search/Thread.zig | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 275af6d93..1ffb420f0 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -643,8 +643,20 @@ const Search = struct { // found the viewport/active area dirty, so we should mark it as // dirty in our viewport searcher so it forces a re-search. if (t.flags.search_viewport_dirty) { - self.viewport.active_dirty = true; t.flags.search_viewport_dirty = false; + + // Mark our viewport dirty so it researches the active + self.viewport.active_dirty = true; + + // Reload our active area for our active screen + if (self.screens.getPtr(t.screens.active_key)) |screen_search| { + screen_search.reloadActive() catch |err| switch (err) { + error.OutOfMemory => log.warn( + "error reloading active area for screen key={} err={}", + .{ t.screens.active_key, err }, + ), + }; + } } // Check our viewport for changes. From 1bb2d4f1c23e5686192b7ef36dd579e4aa4ffda7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 21:42:05 -0800 Subject: [PATCH 158/209] macos: only end search if we previously had one --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index f431fdf6d..e2feb79c4 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -75,7 +75,7 @@ extension Ghostty { let action = "search:\(needle)" ghostty_surface_binding_action(surface, action, UInt(action.count)) } - } else { + } else if oldValue != nil { searchNeedleCancellable = nil guard let surface = self.surface else { return } let action = "search:" From ad755b0e3d987af71c7adbb870d771aa0100c716 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 21:46:13 -0800 Subject: [PATCH 159/209] core: always send start_search for refocus --- src/Surface.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 87cbd05b9..c3740fd71 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4932,16 +4932,16 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool self.renderer_state.terminal.fullReset(); }, - .start_search => if (self.search == null) { + .start_search => { // To save resources, we don't actually start a search here, // we just notify the apprt. The real thread will start when // the first needles are set. - _ = try self.rt_app.performAction( + return try self.rt_app.performAction( .{ .surface = self }, .start_search, .{ .needle = "" }, ); - } else return false, + }, .search => |text| search: { const s: *Search = if (self.search) |*s| s else init: { From 330ce07d48261cf37e1aa0cb05a1a08cbcb866a3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 21:51:54 -0800 Subject: [PATCH 160/209] terminal: fix moving selection on history changing --- src/terminal/search/screen.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index bd5aa80a5..ac03dd65a 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -493,6 +493,10 @@ pub const ScreenSearch = struct { // in our history (fast path) if (results.items.len == 0) break :history; + // The number added to our history. Needed for updating + // our selection if we have one. + const added_len = results.items.len; + // Matches! Reverse our list then append all the remaining // history items that didn't start on our original node. std.mem.reverse(FlattenedHighlight, results.items); @@ -505,7 +509,7 @@ pub const ScreenSearch = struct { if (self.selected) |*m| selected: { const active_len = self.active_results.items.len; if (m.idx < active_len) break :selected; - m.idx += results.items.len; + m.idx += added_len; // Moving the idx should not change our targeted result // since the history is immutable. From f252db1f1cdc1ca28cc5f9679f541f542844589e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 22:10:44 -0800 Subject: [PATCH 161/209] terminal: handle pruning history for when active area removes it --- src/terminal/search/screen.zig | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index ac03dd65a..97784e97e 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -518,6 +518,26 @@ pub const ScreenSearch = struct { assert(m.highlight.start.eql(hl.startPin())); } } + } else { + // No history node means we have no history + if (self.history) |*h| { + h.deinit(self.screen); + self.history = null; + for (self.history_results.items) |*hl| hl.deinit(alloc); + self.history_results.clearRetainingCapacity(); + } + + // If we have a selection in the history area, we need to + // move it to the end of the active area. + if (self.selected) |*m| selected: { + const active_len = self.active_results.items.len; + if (m.idx < active_len) break :selected; + m.deinit(self.screen); + self.selected = null; + _ = self.select(.prev) catch |err| { + log.info("reload failed to reset search selection err={}", .{err}); + }; + } } // Figure out if we need to fixup our selection later because From f91080a1650162060cf2fb2eae6af690ea6d773f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 06:52:16 -0800 Subject: [PATCH 162/209] terminal: fix single-character search crashes --- src/terminal/search/sliding_window.zig | 112 ++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 2 deletions(-) diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index 66f7bc70c..0d853b3a0 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -222,10 +222,17 @@ pub const SlidingWindow = struct { ); } + // Special case 1-lengthed needles to delete the entire buffer. + if (self.needle.len == 1) { + self.clearAndRetainCapacity(); + self.assertIntegrity(); + return null; + } + // No match. We keep `needle.len - 1` bytes available to // handle the future overlap case. - var meta_it = self.meta.iterator(.reverse); prune: { + var meta_it = self.meta.iterator(.reverse); var saved: usize = 0; while (meta_it.next()) |meta| { const needed = self.needle.len - 1 - saved; @@ -606,7 +613,7 @@ pub const SlidingWindow = struct { assert(data_len == self.data.len()); // Integrity check: verify our data offset is within bounds. - assert(self.data_offset < self.data.len()); + assert(self.data.len() == 0 or self.data_offset < self.data.len()); } }; @@ -709,6 +716,52 @@ test "SlidingWindow single append case insensitive ASCII" { try testing.expect(w.next() == null); try testing.expect(w.next() == null); } + +test "SlidingWindow single append single char" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "b"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello. boo! hello. boo!"); + + // We want to test single-page cases. + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + + // We should be able to find two matches. + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end)); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end)); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + test "SlidingWindow single append no match" { const testing = std.testing; const alloc = testing.allocator; @@ -788,6 +841,61 @@ test "SlidingWindow two pages" { try testing.expect(w.next() == null); } +test "SlidingWindow two pages single char" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "b"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\n"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + try s.testWriteString("hello. boo!"); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node.next.?); + + // Search should find two matches + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 76, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 76, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + test "SlidingWindow two pages match across boundary" { const testing = std.testing; const alloc = testing.allocator; From 339abf97f74b40e6fa10f21cb7b49a5a7ce71bd9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 06:53:38 -0800 Subject: [PATCH 163/209] macos: can allow single char searches now --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index e2feb79c4..cd8c7ccb5 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -71,7 +71,7 @@ extension Ghostty { if let searchState { searchNeedleCancellable = searchState.$needle.removeDuplicates().sink { [weak self] needle in guard let surface = self?.surface else { return } - guard needle.count > 1 else { return } + guard needle.count > 0 else { return } let action = "search:\(needle)" ghostty_surface_binding_action(surface, action, UInt(action.count)) } From f7b14a0142093af5b6e53d8e51c1ad7bc4dbd5a4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 06:59:16 -0800 Subject: [PATCH 164/209] macos: debounce search requests with length less than 3 --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index cd8c7ccb5..071131b42 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -69,12 +69,28 @@ extension Ghostty { @Published var searchState: SearchState? = nil { didSet { if let searchState { - searchNeedleCancellable = searchState.$needle.removeDuplicates().sink { [weak self] needle in - guard let surface = self?.surface else { return } - guard needle.count > 0 else { return } - let action = "search:\(needle)" - ghostty_surface_binding_action(surface, action, UInt(action.count)) - } + // I'm not a Combine expert so if there is a better way to do this I'm + // all ears. What we're doing here is grabbing the latest needle. If the + // needle is less than 3 chars, we debounce it for a few hundred ms to + // avoid kicking off expensive searches. + searchNeedleCancellable = searchState.$needle + .removeDuplicates() + .filter { $0.count > 0 } + .map { needle -> AnyPublisher in + if needle.count >= 3 { + return Just(needle).eraseToAnyPublisher() + } else { + return Just(needle) + .delay(for: .milliseconds(300), scheduler: DispatchQueue.main) + .eraseToAnyPublisher() + } + } + .switchToLatest() + .sink { [weak self] needle in + guard let surface = self?.surface else { return } + let action = "search:\(needle)" + ghostty_surface_binding_action(surface, action, UInt(action.count)) + } } else if oldValue != nil { searchNeedleCancellable = nil guard let surface = self.surface else { return } From c51170da9c260309235d0240338f7e29c95c9f3c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 07:05:52 -0800 Subject: [PATCH 165/209] add end_search binding --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 3 +- src/Surface.zig | 30 +++++++++---------- src/config/Config.zig | 2 +- src/input/Binding.zig | 10 ++++++- src/input/command.zig | 6 ++++ 5 files changed, 31 insertions(+), 20 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 071131b42..8aa108f3f 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -75,9 +75,8 @@ extension Ghostty { // avoid kicking off expensive searches. searchNeedleCancellable = searchState.$needle .removeDuplicates() - .filter { $0.count > 0 } .map { needle -> AnyPublisher in - if needle.count >= 3 { + if needle.isEmpty || needle.count >= 3 { return Just(needle).eraseToAnyPublisher() } else { return Just(needle) diff --git a/src/Surface.zig b/src/Surface.zig index c3740fd71..698d1844b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4943,19 +4943,24 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ); }, + .end_search => { + if (self.search) |*s| { + s.deinit(); + self.search = null; + } + + return try self.rt_app.performAction( + .{ .surface = self }, + .end_search, + {}, + ); + }, + .search => |text| search: { const s: *Search = if (self.search) |*s| s else init: { // If we're stopping the search and we had no prior search, // then there is nothing to do. - if (text.len == 0) { - // So GUIs can hide visible search widgets. - _ = try self.rt_app.performAction( - .{ .surface = self }, - .end_search, - {}, - ); - return false; - } + if (text.len == 0) return false; // We need to assign directly to self.search because we need // a stable pointer back to the thread state. @@ -4985,13 +4990,6 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool if (text.len == 0) { s.deinit(); self.search = null; - - // Notify apprt search has ended. - _ = try self.rt_app.performAction( - .{ .surface = self }, - .end_search, - {}, - ); break :search; } diff --git a/src/config/Config.zig b/src/config/Config.zig index e34666ecb..e6f7fb173 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6413,7 +6413,7 @@ pub const Keybinds = struct { try self.set.putFlags( alloc, .{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true, .shift = true } }, - .{ .search = "" }, + .end_search, .{ .performable = true }, ); try self.set.putFlags( diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 636f343e3..1e7db3592 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -333,7 +333,11 @@ pub const Action = union(enum) { set_font_size: f32, /// Start a search for the given text. If the text is empty, then - /// the search is canceled. If a previous search is active, it is replaced. + /// the search is canceled. A canceled search will not disable any GUI + /// elements showing search. For that, the explicit end_search binding + /// should be used. + /// + /// If a previous search is active, it is replaced. search: []const u8, /// Navigate the search results. If there is no active search, this @@ -344,6 +348,9 @@ pub const Action = union(enum) { /// search terms, but opens the UI for searching. start_search, + /// End the current search if any and hide any GUI elements. + end_search, + /// Clear the screen and all scrollback. clear_screen, @@ -1172,6 +1179,7 @@ pub const Action = union(enum) { .search, .navigate_search, .start_search, + .end_search, .reset, .copy_to_clipboard, .copy_url_to_clipboard, diff --git a/src/input/command.zig b/src/input/command.zig index 9f1d4d3d5..7cbff405a 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -169,6 +169,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Start a search if one isn't already active.", }}, + .end_search => comptime &.{.{ + .action = .end_search, + .title = "End Search", + .description = "End the current search if any and hide any GUI elements.", + }}, + .navigate_search => comptime &.{ .{ .action = .{ .navigate_search = .next }, .title = "Next Search Result", From 5b4394d211b9a4d4ce0460ff55a1a6345e2fe939 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 08:54:48 -0800 Subject: [PATCH 166/209] macos: end_search for ending search --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 4 ++-- src/Surface.zig | 9 ++++++++- src/config/Config.zig | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 8aa108f3f..83e66ab81 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -93,7 +93,7 @@ extension Ghostty { } else if oldValue != nil { searchNeedleCancellable = nil guard let surface = self.surface else { return } - let action = "search:" + let action = "end_search" ghostty_surface_binding_action(surface, action, UInt(action.count)) } } @@ -1512,7 +1512,7 @@ extension Ghostty { @IBAction func findHide(_ sender: Any?) { guard let surface = self.surface else { return } - let action = "search:" + let action = "end_search" if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { AppDelegate.logger.warning("action failed action=\(action)") } diff --git a/src/Surface.zig b/src/Surface.zig index 698d1844b..d0866e901 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4944,16 +4944,23 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, .end_search => { + // We only return that this was performed if we actually + // stopped a search, but we also send the apprt end_search so + // that GUIs can clean up stale stuff. + const performed = self.search != null; + if (self.search) |*s| { s.deinit(); self.search = null; } - return try self.rt_app.performAction( + _ = try self.rt_app.performAction( .{ .surface = self }, .end_search, {}, ); + + return performed; }, .search => |text| search: { diff --git a/src/config/Config.zig b/src/config/Config.zig index e6f7fb173..18412ff0e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6419,7 +6419,7 @@ pub const Keybinds = struct { try self.set.putFlags( alloc, .{ .key = .{ .physical = .escape } }, - .{ .search = "" }, + .end_search, .{ .performable = true }, ); try self.set.putFlags( From f5b923573d2ef90a2942913237df7c0b2085ef69 Mon Sep 17 00:00:00 2001 From: avarayr <7735415+avarayr@users.noreply.github.com> Date: Wed, 26 Nov 2025 13:04:05 -0500 Subject: [PATCH 167/209] macOS: move search result counter inside text field Move the search result counter (e.g. "1/30") inside the search text field using an overlay, preventing layout shift when results appear. This PR was authored with Claude Code. --- macos/Sources/Ghostty/SurfaceView.swift | 32 ++++++++++++++----------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 6d17258d8..5a746a2c7 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -409,11 +409,27 @@ extension Ghostty { TextField("Search", text: $searchState.needle) .textFieldStyle(.plain) .frame(width: 180) - .padding(.horizontal, 8) + .padding(.leading, 8) + .padding(.trailing, 50) .padding(.vertical, 6) .background(Color.primary.opacity(0.1)) .cornerRadius(6) .focused($isSearchFieldFocused) + .overlay(alignment: .trailing) { + if let selected = searchState.selected { + Text("\(selected + 1)/\(searchState.total, default: "?")") + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + .padding(.trailing, 8) + } else if let total = searchState.total { + Text("-/\(total)") + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + .padding(.trailing, 8) + } + } #if canImport(AppKit) .onExitCommand { Ghostty.moveFocus(to: surfaceView) @@ -427,19 +443,7 @@ extension Ghostty { ghostty_surface_binding_action(surface, action, UInt(action.count)) return .handled } - - if let selected = searchState.selected { - Text("\(selected + 1)/\(searchState.total, default: "?")") - .font(.caption) - .foregroundColor(.secondary) - .monospacedDigit() - } else if let total = searchState.total { - Text("-/\(total)") - .font(.caption) - .foregroundColor(.secondary) - .monospacedDigit() - } - + Button(action: { guard let surface = surfaceView.surface else { return } let action = "navigate_search:next" From d85fc62774f0c4b3f374c17c26ae0db558e112bc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 10:04:26 -0800 Subject: [PATCH 168/209] search: reset selected match when the needle changes --- src/terminal/search/Thread.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 1ffb420f0..8addd6ba9 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -291,6 +291,10 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void { .{ .total_matches = 0 }, self.opts.event_userdata, ); + cb( + .{ .selected_match = null }, + self.opts.event_userdata, + ); cb( .{ .viewport_matches = &.{} }, self.opts.event_userdata, From 9206b3dc9bced169f02aaa78b71775cdac5d6252 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 10:26:34 -0800 Subject: [PATCH 169/209] renderer: manual selection should take priority over search matches Previously it was impossible to select a search match. Well, it was selecting but it wasn't showing that it was selected. --- src/renderer/generic.zig | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index df36c4a7e..8c55da602 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -2602,11 +2602,25 @@ pub fn Renderer(comptime GraphicsAPI: type) type { search, search_selected, } = selected: { + // Order below matters for precedence. + + // Selection should take the highest precedence. + const x_compare = if (wide == .spacer_tail) + x -| 1 + else + x; + if (selection) |sel| { + if (x_compare >= sel[0] and + x_compare <= sel[1]) break :selected .selection; + } + // If we're highlighted, then we're selected. In the // future we want to use a different style for this // but this to get started. for (highlights.items) |hl| { - if (x >= hl.range[0] and x <= hl.range[1]) { + if (x_compare >= hl.range[0] and + x_compare <= hl.range[1]) + { const tag: HighlightTag = @enumFromInt(hl.tag); break :selected switch (tag) { .search_match => .search, @@ -2615,15 +2629,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } - const sel = selection orelse break :selected .false; - const x_compare = if (wide == .spacer_tail) - x -| 1 - else - x; - - if (x_compare >= sel[0] and - x_compare <= sel[1]) break :selected .selection; - break :selected .false; }; From 4b01163c79e959d85a2ed1de91e19b9a17e3c3f3 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 26 Nov 2025 11:59:42 -0700 Subject: [PATCH 170/209] fix(macos): use strings' utf-8 lengths for libghostty calls Swift conveniently converts strings to UTF-8 encoded cstrings when passing them to external functions, however our libghostty functions also take a length and we were using String.count for that, which returns the number of _characters_ not the byte length, which caused searches with multi-byte characters to get truncated. I went ahead and changed _all_ invocations that pass a string length to use the utf-8 byte length even if the string is comptime-known and all ASCII, just so that it's proper and if someone copies one of the calls in the future for user-inputted data they don't reproduce this bug. ref: https://developer.apple.com/documentation/swift/string/count https://developer.apple.com/documentation/swift/stringprotocol/lengthofbytes(using:) --- macos/Sources/Ghostty/Ghostty.App.swift | 14 +-- macos/Sources/Ghostty/Ghostty.Config.swift | 102 +++++++++--------- macos/Sources/Ghostty/SurfaceView.swift | 6 +- .../Sources/Ghostty/SurfaceView_AppKit.swift | 26 ++--- 4 files changed, 74 insertions(+), 74 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 9c1acd1a8..39ebbb51f 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -180,14 +180,14 @@ extension Ghostty { func newTab(surface: ghostty_surface_t) { let action = "new_tab" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } func newWindow(surface: ghostty_surface_t) { let action = "new_window" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } @@ -210,14 +210,14 @@ extension Ghostty { func splitToggleZoom(surface: ghostty_surface_t) { let action = "toggle_split_zoom" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } func toggleFullscreen(surface: ghostty_surface_t) { let action = "toggle_fullscreen" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } @@ -238,21 +238,21 @@ extension Ghostty { case .reset: action = "reset_font_size" } - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } func toggleTerminalInspector(surface: ghostty_surface_t) { let action = "inspector:toggle" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } func resetTerminal(surface: ghostty_surface_t) { let action = "reset" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index f380345c7..2df0a8656 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -105,7 +105,7 @@ extension Ghostty { func keyboardShortcut(for action: String) -> KeyboardShortcut? { guard let cfg = self.config else { return nil } - let trigger = ghostty_config_trigger(cfg, action, UInt(action.count)) + let trigger = ghostty_config_trigger(cfg, action, UInt(action.lengthOfBytes(using: .utf8))) return Ghostty.keyboardShortcut(for: trigger) } #endif @@ -120,7 +120,7 @@ extension Ghostty { guard let config = self.config else { return .init() } var v: CUnsignedInt = 0 let key = "bell-features" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .init() } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .init() } return .init(rawValue: v) } @@ -128,7 +128,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = true; let key = "initial-window" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -136,7 +136,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false; let key = "quit-after-last-window-closed" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -144,7 +144,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: UnsafePointer? = nil let key = "title" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard let ptr = v else { return nil } return String(cString: ptr) } @@ -153,7 +153,7 @@ extension Ghostty { guard let config = self.config else { return "" } var v: UnsafePointer? = nil let key = "window-save-state" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return "" } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return "" } guard let ptr = v else { return "" } return String(cString: ptr) } @@ -162,21 +162,21 @@ extension Ghostty { guard let config = self.config else { return nil } var v: Int16 = 0 let key = "window-position-x" - return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil + return ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) ? v : nil } var windowPositionY: Int16? { guard let config = self.config else { return nil } var v: Int16 = 0 let key = "window-position-y" - return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil + return ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) ? v : nil } var windowNewTabPosition: String { guard let config = self.config else { return "" } var v: UnsafePointer? = nil let key = "window-new-tab-position" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return "" } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return "" } guard let ptr = v else { return "" } return String(cString: ptr) } @@ -186,7 +186,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "window-decoration" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return WindowDecoration(rawValue: str)?.enabled() ?? defaultValue @@ -196,7 +196,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: UnsafePointer? = nil let key = "window-theme" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard let ptr = v else { return nil } return String(cString: ptr) } @@ -205,7 +205,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false let key = "window-step-resize" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -213,7 +213,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false let key = "fullscreen" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -223,7 +223,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-non-native-fullscreen" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return switch str { @@ -245,7 +245,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: UnsafePointer? = nil let key = "window-title-font-family" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard let ptr = v else { return nil } return String(cString: ptr) } @@ -255,7 +255,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-window-buttons" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacOSWindowButtons(rawValue: str) ?? defaultValue @@ -266,7 +266,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-titlebar-style" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } return String(cString: ptr) } @@ -276,7 +276,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-titlebar-proxy-icon" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacOSTitlebarProxyIcon(rawValue: str) ?? defaultValue @@ -287,7 +287,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-dock-drop-behavior" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacDockDropBehavior(rawValue: str) ?? defaultValue @@ -297,7 +297,7 @@ extension Ghostty { guard let config = self.config else { return false } var v = false; let key = "macos-window-shadow" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -306,7 +306,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-icon" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacOSIcon(rawValue: str) ?? defaultValue @@ -318,7 +318,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-custom-icon" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } guard let path = NSString(utf8String: ptr) else { return defaultValue } return path.expandingTildeInPath @@ -332,7 +332,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-icon-frame" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacOSIconFrame(rawValue: str) ?? defaultValue @@ -342,7 +342,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: ghostty_config_color_s = .init() let key = "macos-icon-ghost-color" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } return .init(ghostty: v) } @@ -350,7 +350,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: ghostty_config_color_list_s = .init() let key = "macos-icon-screen-color" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard v.len > 0 else { return nil } let buffer = UnsafeBufferPointer(start: v.colors, count: v.len) return buffer.map { .init(ghostty: $0) } @@ -360,7 +360,7 @@ extension Ghostty { guard let config = self.config else { return .never } var v: UnsafePointer? = nil let key = "macos-hidden" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .never } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .never } guard let ptr = v else { return .never } let str = String(cString: ptr) return MacHidden(rawValue: str) ?? .never @@ -370,14 +370,14 @@ extension Ghostty { guard let config = self.config else { return false } var v = false; let key = "focus-follows-mouse" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } var backgroundColor: Color { var color: ghostty_config_color_s = .init(); let bg_key = "background" - if (!ghostty_config_get(config, &color, bg_key, UInt(bg_key.count))) { + if (!ghostty_config_get(config, &color, bg_key, UInt(bg_key.lengthOfBytes(using: .utf8)))) { #if os(macOS) return Color(NSColor.windowBackgroundColor) #elseif os(iOS) @@ -398,7 +398,7 @@ extension Ghostty { guard let config = self.config else { return 1 } var v: Double = 1 let key = "background-opacity" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v; } @@ -406,7 +406,7 @@ extension Ghostty { guard let config = self.config else { return 1 } var v: Int = 0 let key = "background-blur" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v; } @@ -414,7 +414,7 @@ extension Ghostty { guard let config = self.config else { return 1 } var opacity: Double = 0.85 let key = "unfocused-split-opacity" - _ = ghostty_config_get(config, &opacity, key, UInt(key.count)) + _ = ghostty_config_get(config, &opacity, key, UInt(key.lengthOfBytes(using: .utf8))) return 1 - opacity } @@ -423,9 +423,9 @@ extension Ghostty { var color: ghostty_config_color_s = .init(); let key = "unfocused-split-fill" - if (!ghostty_config_get(config, &color, key, UInt(key.count))) { + if (!ghostty_config_get(config, &color, key, UInt(key.lengthOfBytes(using: .utf8)))) { let bg_key = "background" - _ = ghostty_config_get(config, &color, bg_key, UInt(bg_key.count)); + _ = ghostty_config_get(config, &color, bg_key, UInt(bg_key.lengthOfBytes(using: .utf8))); } return .init( @@ -444,7 +444,7 @@ extension Ghostty { var color: ghostty_config_color_s = .init(); let key = "split-divider-color" - if (!ghostty_config_get(config, &color, key, UInt(key.count))) { + if (!ghostty_config_get(config, &color, key, UInt(key.lengthOfBytes(using: .utf8)))) { return Color(newColor) } @@ -460,7 +460,7 @@ extension Ghostty { guard let config = self.config else { return .top } var v: UnsafePointer? = nil let key = "quick-terminal-position" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .top } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .top } guard let ptr = v else { return .top } let str = String(cString: ptr) return QuickTerminalPosition(rawValue: str) ?? .top @@ -470,7 +470,7 @@ extension Ghostty { guard let config = self.config else { return .main } var v: UnsafePointer? = nil let key = "quick-terminal-screen" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .main } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .main } guard let ptr = v else { return .main } let str = String(cString: ptr) return QuickTerminalScreen(fromGhosttyConfig: str) ?? .main @@ -480,7 +480,7 @@ extension Ghostty { guard let config = self.config else { return 0.2 } var v: Double = 0.2 let key = "quick-terminal-animation-duration" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -488,7 +488,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = true let key = "quick-terminal-autohide" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -496,7 +496,7 @@ extension Ghostty { guard let config = self.config else { return .move } var v: UnsafePointer? = nil let key = "quick-terminal-space-behavior" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .move } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .move } guard let ptr = v else { return .move } let str = String(cString: ptr) return QuickTerminalSpaceBehavior(fromGhosttyConfig: str) ?? .move @@ -506,7 +506,7 @@ extension Ghostty { guard let config = self.config else { return QuickTerminalSize() } var v = ghostty_config_quick_terminal_size_s() let key = "quick-terminal-size" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return QuickTerminalSize() } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return QuickTerminalSize() } return QuickTerminalSize(from: v) } #endif @@ -515,7 +515,7 @@ extension Ghostty { guard let config = self.config else { return .after_first } var v: UnsafePointer? = nil let key = "resize-overlay" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .after_first } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .after_first } guard let ptr = v else { return .after_first } let str = String(cString: ptr) return ResizeOverlay(rawValue: str) ?? .after_first @@ -526,7 +526,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "resize-overlay-position" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return ResizeOverlayPosition(rawValue: str) ?? defaultValue @@ -536,7 +536,7 @@ extension Ghostty { guard let config = self.config else { return 1000 } var v: UInt = 0 let key = "resize-overlay-duration" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v; } @@ -544,7 +544,7 @@ extension Ghostty { guard let config = self.config else { return .seconds(5) } var v: UInt = 0 let key = "undo-timeout" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return .milliseconds(v) } @@ -552,7 +552,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: UnsafePointer? = nil let key = "auto-update" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard let ptr = v else { return nil } let str = String(cString: ptr) return AutoUpdate(rawValue: str) @@ -563,7 +563,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "auto-update-channel" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return AutoUpdateChannel(rawValue: str) ?? defaultValue @@ -573,7 +573,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false; let key = "macos-auto-secure-input" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -581,7 +581,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false; let key = "macos-secure-input-indication" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -589,7 +589,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false; let key = "maximize" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -598,7 +598,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-shortcuts" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacShortcuts(rawValue: str) ?? defaultValue @@ -609,7 +609,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "scrollbar" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return Scrollbar(rawValue: str) ?? defaultValue diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 5a746a2c7..c3726ad32 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -440,14 +440,14 @@ extension Ghostty { let action = modifiers.contains(.shift) ? "navigate_search:previous" : "navigate_search:next" - ghostty_surface_binding_action(surface, action, UInt(action.count)) + ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) return .handled } Button(action: { guard let surface = surfaceView.surface else { return } let action = "navigate_search:next" - ghostty_surface_binding_action(surface, action, UInt(action.count)) + ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) }) { Image(systemName: "chevron.up") } @@ -456,7 +456,7 @@ extension Ghostty { Button(action: { guard let surface = surfaceView.surface else { return } let action = "navigate_search:previous" - ghostty_surface_binding_action(surface, action, UInt(action.count)) + ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) }) { Image(systemName: "chevron.down") } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 83e66ab81..03ef293af 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -88,13 +88,13 @@ extension Ghostty { .sink { [weak self] needle in guard let surface = self?.surface else { return } let action = "search:\(needle)" - ghostty_surface_binding_action(surface, action, UInt(action.count)) + ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) } } else if oldValue != nil { searchNeedleCancellable = nil guard let surface = self.surface else { return } let action = "end_search" - ghostty_surface_binding_action(surface, action, UInt(action.count)) + ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) } } } @@ -1448,7 +1448,7 @@ extension Ghostty { @IBAction func copy(_ sender: Any?) { guard let surface = self.surface else { return } let action = "copy_to_clipboard" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1456,7 +1456,7 @@ extension Ghostty { @IBAction func paste(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_clipboard" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1465,7 +1465,7 @@ extension Ghostty { @IBAction func pasteAsPlainText(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_clipboard" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1473,7 +1473,7 @@ extension Ghostty { @IBAction func pasteSelection(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_selection" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1481,7 +1481,7 @@ extension Ghostty { @IBAction override func selectAll(_ sender: Any?) { guard let surface = self.surface else { return } let action = "select_all" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1489,7 +1489,7 @@ extension Ghostty { @IBAction func find(_ sender: Any?) { guard let surface = self.surface else { return } let action = "start_search" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1497,7 +1497,7 @@ extension Ghostty { @IBAction func findNext(_ sender: Any?) { guard let surface = self.surface else { return } let action = "search:next" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1505,7 +1505,7 @@ extension Ghostty { @IBAction func findPrevious(_ sender: Any?) { guard let surface = self.surface else { return } let action = "search:previous" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1513,7 +1513,7 @@ extension Ghostty { @IBAction func findHide(_ sender: Any?) { guard let surface = self.surface else { return } let action = "end_search" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1541,7 +1541,7 @@ extension Ghostty { @objc func resetTerminal(_ sender: Any) { guard let surface = self.surface else { return } let action = "reset" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1549,7 +1549,7 @@ extension Ghostty { @objc func toggleTerminalInspector(_ sender: Any) { guard let surface = self.surface else { return } let action = "inspector:toggle" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } From cbcd52846c8d725ee87de99dc32b707d68a556cf Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Wed, 26 Nov 2025 20:59:43 +0100 Subject: [PATCH 171/209] macOS: fix search dragging animation when corner is not changed --- macos/Sources/Ghostty/SurfaceView.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index c3726ad32..66f77637a 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -499,11 +499,13 @@ extension Ghostty { x: centerPos.x + value.translation.width, y: centerPos.y + value.translation.height ) - corner = closestCorner(to: newCenter, in: geo.size) - dragOffset = .zero + let newCorner = closestCorner(to: newCenter, in: geo.size) + withAnimation(.easeOut(duration: 0.2)) { + corner = newCorner + dragOffset = .zero + } } ) - .animation(.easeOut(duration: 0.2), value: corner) } } From dc08d057fe6ec3822c0af94eb58ad4632429e884 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Wed, 26 Nov 2025 21:00:08 +0100 Subject: [PATCH 172/209] macOS: use ConcentricRectangle on Tahoe --- macos/Sources/Ghostty/SurfaceView.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 66f77637a..6f21c997b 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -469,7 +469,7 @@ extension Ghostty { } .padding(8) .background(.background) - .cornerRadius(8) + .clipShape(clipShape) .shadow(radius: 4) .onAppear { isSearchFieldFocused = true @@ -508,7 +508,15 @@ extension Ghostty { ) } } - + + private var clipShape: some Shape { + if #available(iOS 26.0, macOS 26.0, *) { + return ConcentricRectangle(corners: .concentric(minimum: 8), isUniform: true) + } else { + return RoundedRectangle(cornerRadius: 8) + } + } + enum Corner { case topLeft, topRight, bottomLeft, bottomRight From b96b55ebde55d8bde2853622627188c2e0d29c9d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 13:16:03 -0800 Subject: [PATCH 173/209] terminal: RenderState must consider first row in dirty page dirty --- src/terminal/render.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 6acf88dcb..296360381 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -441,6 +441,7 @@ pub const RenderState = struct { // faster than iterating pages again later. if (last_dirty_page) |last_p| last_p.dirty = false; last_dirty_page = p; + break :dirty; } // If our row is dirty then we're dirty. From 842becbcaf3aaeff663f7c3f3db771e28868bf6a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 16:05:12 -0800 Subject: [PATCH 174/209] terminal: PageList search should halt when pin becomes garbage This means that the pin we're using to track our position in the PageList was part of a node that got reused/recycled at some point. We can't make any meaningful guarantees about the state of the PageList. This only happens with scrollback pruning so we can treat it as a complete search. --- src/terminal/search/pagelist.zig | 50 ++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/terminal/search/pagelist.zig b/src/terminal/search/pagelist.zig index bd1ce9ef7..227bd03f9 100644 --- a/src/terminal/search/pagelist.zig +++ b/src/terminal/search/pagelist.zig @@ -112,6 +112,11 @@ pub const PageListSearch = struct { /// This returns false if there is no more data to feed. This essentially /// means we've searched the entire pagelist. pub fn feed(self: *PageListSearch) Allocator.Error!bool { + // If our pin becomes garbage it means wherever we were next + // was reused and we can't make sense of our progress anymore. + // It is effectively equivalent to reaching the end of the PageList. + if (self.pin.garbage) return false; + // Add at least enough data to find a single match. var rem = self.window.needle.len; @@ -392,3 +397,48 @@ test "feed with match spanning page boundary with newline" { try testing.expect(search.next() == null); try testing.expect(!try search.feed()); } + +test "feed with pruned page" { + const alloc = testing.allocator; + + // Zero here forces minimum max size to effectively two pages. + var p: PageList = try .init(alloc, 80, 24, 0); + defer p.deinit(); + + // Grow to capacity + const page1_node = p.pages.last.?; + const page1 = page1_node.data; + for (0..page1.capacity.rows - page1.size.rows) |_| { + try testing.expect(try p.grow() == null); + } + + // Grow and allocate one more page. Then fill that page up. + const page2_node = (try p.grow()).?; + const page2 = page2_node.data; + for (0..page2.capacity.rows - page2.size.rows) |_| { + try testing.expect(try p.grow() == null); + } + + // Setup search and feed until we can't + var search: PageListSearch = try .init( + alloc, + "Test", + &p, + p.pages.last.?, + ); + defer search.deinit(); + try testing.expect(try search.feed()); + try testing.expect(!try search.feed()); + + // Next should create a new page, but it should reuse our first + // page since we're at max size. + const new = (try p.grow()).?; + try testing.expect(p.pages.last.? == new); + + // Our first should now be page2 and our last should be page1 + try testing.expectEqual(page2_node, p.pages.first.?); + try testing.expectEqual(page1_node, p.pages.last.?); + + // Feed should still do nothing + try testing.expect(!try search.feed()); +} From 4ff0e0c9d251ddd0687ead578a90d58d63f9840b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 27 Nov 2025 07:21:56 -0800 Subject: [PATCH 175/209] input: remove the unused end search entry in the palette --- src/input/command.zig | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/input/command.zig b/src/input/command.zig index 7cbff405a..3879efc36 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -185,12 +185,6 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Navigate to the previous search result, if any.", } }, - .search => comptime &.{.{ - .action = .{ .search = "" }, - .title = "End Search", - .description = "End a search if one is active.", - }}, - .increase_font_size => comptime &.{.{ .action = .{ .increase_font_size = 1 }, .title = "Increase Font Size", @@ -633,6 +627,7 @@ fn actionCommands(action: Action.Key) []const Command { .esc, .cursor_key, .set_font_size, + .search, .scroll_to_row, .scroll_page_fractional, .scroll_page_lines, From 5c1679209dbf4817e515f6d3204bec9685bf3a07 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 27 Nov 2025 12:56:49 -0800 Subject: [PATCH 176/209] macos: add hover styles to search buttons, cursor changes --- macos/Sources/Ghostty/SurfaceView.swift | 37 ++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 6f21c997b..ba678db59 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -405,7 +405,7 @@ extension Ghostty { var body: some View { GeometryReader { geo in - HStack(spacing: 8) { + HStack(spacing: 4) { TextField("Search", text: $searchState.needle) .textFieldStyle(.plain) .frame(width: 180) @@ -451,7 +451,7 @@ extension Ghostty { }) { Image(systemName: "chevron.up") } - .buttonStyle(.borderless) + .buttonStyle(SearchButtonStyle()) Button(action: { guard let surface = surfaceView.surface else { return } @@ -460,12 +460,12 @@ extension Ghostty { }) { Image(systemName: "chevron.down") } - .buttonStyle(.borderless) + .buttonStyle(SearchButtonStyle()) Button(action: onClose) { Image(systemName: "xmark") } - .buttonStyle(.borderless) + .buttonStyle(SearchButtonStyle()) } .padding(8) .background(.background) @@ -556,6 +556,35 @@ extension Ghostty { return point.y < midY ? .topRight : .bottomRight } } + + struct SearchButtonStyle: ButtonStyle { + @State private var isHovered = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(isHovered || configuration.isPressed ? .primary : .secondary) + .padding(.horizontal, 2) + .frame(height: 26) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(backgroundColor(isPressed: configuration.isPressed)) + ) + .onHover { hovering in + isHovered = hovering + } + .backport.pointerStyle(.link) + } + + private func backgroundColor(isPressed: Bool) -> Color { + if isPressed { + return Color.primary.opacity(0.2) + } else if isHovered { + return Color.primary.opacity(0.1) + } else { + return Color.clear + } + } + } } /// A surface is terminology in Ghostty for a terminal surface, or a place where a terminal is actually drawn From dbfc3eb67990543f9c243dbe0cecd0c87e13ea8c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 27 Nov 2025 13:35:56 -0800 Subject: [PATCH 177/209] Remove unused imports --- src/App.zig | 5 ----- src/Surface.zig | 3 --- src/apprt.zig | 2 -- src/apprt/gtk.zig | 2 -- src/apprt/gtk/App.zig | 5 ----- src/apprt/gtk/cgroup.zig | 3 --- src/apprt/gtk/class/application.zig | 2 -- src/apprt/gtk/class/clipboard_confirmation_dialog.zig | 1 - src/apprt/gtk/class/close_confirmation_dialog.zig | 3 --- src/apprt/gtk/class/config.zig | 2 -- src/apprt/gtk/class/config_errors_dialog.zig | 2 -- src/apprt/gtk/class/debug_warning.zig | 2 -- src/apprt/gtk/class/dialog.zig | 2 -- src/apprt/gtk/class/global_shortcuts.zig | 3 --- src/apprt/gtk/class/inspector_widget.zig | 1 - src/apprt/gtk/class/inspector_window.zig | 3 --- src/apprt/gtk/class/resize_overlay.zig | 1 - src/apprt/gtk/class/split_tree.zig | 7 ------- src/apprt/gtk/class/surface.zig | 2 -- src/apprt/gtk/class/surface_child_exited.zig | 1 - src/apprt/gtk/class/surface_scrolled_window.zig | 1 - src/apprt/gtk/class/surface_title_dialog.zig | 1 - src/apprt/gtk/class/tab.zig | 6 ------ src/apprt/gtk/class/window.zig | 1 - src/apprt/gtk/ext.zig | 1 - src/apprt/gtk/key.zig | 1 - src/apprt/gtk/winproto/wayland.zig | 1 - src/apprt/gtk/winproto/x11.zig | 2 -- src/benchmark/IsSymbol.zig | 1 - src/build/GhosttyBench.zig | 1 - src/build/GhosttyFrameData.zig | 2 -- src/build/GhosttyLibVt.zig | 4 ---- src/build/GhosttyResources.zig | 2 -- src/build/GhosttyWebdata.zig | 1 - src/build/UnicodeTables.zig | 1 - src/build/webgen/main_actions.zig | 1 - src/build_config.zig | 1 - src/cli/boo.zig | 1 - src/cli/list_themes.zig | 2 -- src/cli/ssh_cache.zig | 1 - src/cli/validate_config.zig | 1 - src/config/CApi.zig | 2 -- src/config/Config.zig | 2 -- src/config/command.zig | 1 - src/config/conditional.zig | 1 - src/config/edit.zig | 1 - src/config/theme.zig | 1 - src/datastruct/blocking_queue.zig | 2 -- src/extra/sublime.zig | 1 - src/font/Collection.zig | 1 - src/font/DeferredFace.zig | 1 - src/font/discovery.zig | 1 - src/font/face/freetype.zig | 1 - src/font/face/web_canvas.zig | 1 - src/font/library.zig | 1 - src/font/opentype/head.zig | 1 - src/font/opentype/hhea.zig | 1 - src/font/opentype/os2.zig | 1 - src/font/opentype/post.zig | 1 - src/font/opentype/svg.zig | 1 - src/font/shape.zig | 1 - src/font/shaper/Cache.zig | 1 - src/font/shaper/coretext.zig | 2 -- src/font/shaper/feature.zig | 1 - src/font/shaper/harfbuzz.zig | 1 - src/font/shaper/noop.zig | 2 -- src/font/sprite/Face.zig | 1 - src/font/sprite/draw/block.zig | 4 ---- src/font/sprite/draw/box.zig | 3 --- src/font/sprite/draw/branch.zig | 1 - src/font/sprite/draw/common.zig | 4 ---- src/font/sprite/draw/geometric_shapes.zig | 2 -- src/font/sprite/draw/powerline.zig | 2 -- src/font/sprite/draw/special.zig | 2 -- src/font/sprite/draw/symbols_for_legacy_computing.zig | 2 -- .../draw/symbols_for_legacy_computing_supplement.zig | 2 -- src/input/KeymapDarwin.zig | 1 - src/input/command.zig | 1 - src/input/kitty.zig | 1 - src/input/paste.zig | 1 - src/inspector/cursor.zig | 1 - src/inspector/page.zig | 2 -- src/lib/union.zig | 1 - src/main_bench.zig | 2 -- src/main_gen.zig | 2 -- src/main_ghostty.zig | 6 ------ src/os/TempDir.zig | 1 - src/os/args.zig | 1 - src/os/flatpak.zig | 1 - src/os/homedir.zig | 1 - src/os/mouse.zig | 1 - src/os/wasm/log.zig | 1 - src/os/xdg.zig | 1 - src/renderer.zig | 2 -- src/renderer/OpenGL.zig | 1 - src/renderer/Options.zig | 1 - src/renderer/Thread.zig | 1 - src/renderer/link.zig | 1 - src/renderer/message.zig | 1 - src/renderer/metal/Frame.zig | 4 ---- src/renderer/metal/IOSurfaceLayer.zig | 2 -- src/renderer/metal/Pipeline.zig | 4 ---- src/renderer/metal/RenderPass.zig | 4 ---- src/renderer/metal/Sampler.zig | 2 -- src/renderer/metal/Target.zig | 2 -- src/renderer/metal/Texture.zig | 1 - src/renderer/metal/buffer.zig | 1 - src/renderer/metal/shaders.zig | 1 - src/renderer/opengl/Frame.zig | 4 ---- src/renderer/opengl/Pipeline.zig | 6 ------ src/renderer/opengl/RenderPass.zig | 4 ---- src/renderer/opengl/Sampler.zig | 2 -- src/renderer/opengl/Target.zig | 2 -- src/renderer/opengl/Texture.zig | 2 -- src/renderer/opengl/buffer.zig | 1 - src/renderer/row.zig | 1 - src/renderer/shadertoy.zig | 1 - src/simd/index_of.zig | 1 - src/surface_mouse.zig | 1 - src/terminal/PageList.zig | 1 - src/terminal/Parser.zig | 2 -- src/terminal/Terminal.zig | 1 - src/terminal/apc.zig | 1 - src/terminal/c/key_encode.zig | 1 - src/terminal/c/key_event.zig | 1 - src/terminal/c/osc.zig | 2 -- src/terminal/c/sgr.zig | 2 -- src/terminal/hash_map.zig | 1 - src/terminal/highlight.zig | 1 - src/terminal/hyperlink.zig | 1 - src/terminal/kitty/color.zig | 2 -- src/terminal/kitty/graphics_exec.zig | 2 -- src/terminal/kitty/graphics_image.zig | 1 - src/terminal/kitty/graphics_render.zig | 1 - src/terminal/main.zig | 3 --- src/terminal/page.zig | 1 - src/terminal/parse_table.zig | 1 - src/terminal/point.zig | 1 - src/terminal/ref_counted_set.zig | 2 -- src/terminal/search/Thread.zig | 2 -- src/terminal/search/active.zig | 1 - src/terminal/search/pagelist.zig | 3 --- src/terminal/search/screen.zig | 1 - src/terminal/search/viewport.zig | 1 - src/termio/Options.zig | 2 -- src/termio/Termio.zig | 7 ------- src/termio/Thread.zig | 1 - src/termio/backend.zig | 9 --------- src/termio/mailbox.zig | 2 -- src/termio/message.zig | 2 -- 150 files changed, 275 deletions(-) diff --git a/src/App.zig b/src/App.zig index 2fae4d7df..99d03399c 100644 --- a/src/App.zig +++ b/src/App.zig @@ -7,19 +7,14 @@ const std = @import("std"); const builtin = @import("builtin"); const assert = @import("quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; -const build_config = @import("build_config.zig"); const apprt = @import("apprt.zig"); const Surface = @import("Surface.zig"); -const tracy = @import("tracy"); const input = @import("input.zig"); const configpkg = @import("config.zig"); const Config = configpkg.Config; const BlockingQueue = @import("datastruct/main.zig").BlockingQueue; const renderer = @import("renderer.zig"); const font = @import("font/main.zig"); -const internal_os = @import("os/main.zig"); -const macos = @import("macos"); -const objc = @import("objc"); const log = std.log.scoped(.app); diff --git a/src/Surface.zig b/src/Surface.zig index d0866e901..591ee7220 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -26,9 +26,6 @@ const crash = @import("crash/main.zig"); const unicode = @import("unicode/main.zig"); const rendererpkg = @import("renderer.zig"); const termio = @import("termio.zig"); -const objc = @import("objc"); -const imgui = @import("imgui"); -const Pty = @import("pty.zig").Pty; const font = @import("font/main.zig"); const Command = @import("Command.zig"); const terminal = @import("terminal/main.zig"); diff --git a/src/apprt.zig b/src/apprt.zig index dbd62fbfb..c467f1801 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -8,8 +8,6 @@ //! The goal is to have different implementations share as much of the core //! logic as possible, and to only reach out to platform-specific implementation //! code when absolutely necessary. -const std = @import("std"); -const builtin = @import("builtin"); const build_config = @import("build_config.zig"); const structs = @import("apprt/structs.zig"); diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index aa2404566..415d3773d 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -1,5 +1,3 @@ -const internal_os = @import("../os/main.zig"); - // The required comptime API for any apprt. pub const App = @import("gtk/App.zig"); pub const Surface = @import("gtk/Surface.zig"); diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 4d2006fbb..6c7310339 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -5,18 +5,13 @@ const App = @This(); const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; -const adw = @import("adw"); -const gio = @import("gio"); const apprt = @import("../../apprt.zig"); const configpkg = @import("../../config.zig"); -const internal_os = @import("../../os/main.zig"); const Config = configpkg.Config; const CoreApp = @import("../../App.zig"); const Application = @import("class/application.zig").Application; const Surface = @import("Surface.zig"); -const gtk_version = @import("gtk_version.zig"); -const adw_version = @import("adw_version.zig"); const ipcNewWindow = @import("ipc/new_window.zig").newWindow; const log = std.log.scoped(.gtk); diff --git a/src/apprt/gtk/cgroup.zig b/src/apprt/gtk/cgroup.zig index dbf11a287..654c1e1ac 100644 --- a/src/apprt/gtk/cgroup.zig +++ b/src/apprt/gtk/cgroup.zig @@ -1,14 +1,11 @@ /// Contains all the logic for putting the Ghostty process and /// each individual surface into its own cgroup. const std = @import("std"); -const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const gio = @import("gio"); const glib = @import("glib"); -const gobject = @import("gobject"); -const App = @import("App.zig"); const internal_os = @import("../../os/main.zig"); const log = std.log.scoped(.gtk_systemd_cgroup); diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 9c22782c7..cc070240c 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -1,7 +1,6 @@ const std = @import("std"); const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; -const builtin = @import("builtin"); const adw = @import("adw"); const gdk = @import("gdk"); const gio = @import("gio"); @@ -9,7 +8,6 @@ const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); -const build_config = @import("../../../build_config.zig"); const i18n = @import("../../../os/main.zig").i18n; const apprt = @import("../../../apprt.zig"); const cgroup = @import("../cgroup.zig"); diff --git a/src/apprt/gtk/class/clipboard_confirmation_dialog.zig b/src/apprt/gtk/class/clipboard_confirmation_dialog.zig index 4bcc8696a..d44d38a35 100644 --- a/src/apprt/gtk/class/clipboard_confirmation_dialog.zig +++ b/src/apprt/gtk/class/clipboard_confirmation_dialog.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = @import("../../../quirks.zig").inlineAssert; const adw = @import("adw"); const glib = @import("glib"); const gobject = @import("gobject"); diff --git a/src/apprt/gtk/class/close_confirmation_dialog.zig b/src/apprt/gtk/class/close_confirmation_dialog.zig index e806eb354..5919f9c94 100644 --- a/src/apprt/gtk/class/close_confirmation_dialog.zig +++ b/src/apprt/gtk/class/close_confirmation_dialog.zig @@ -1,13 +1,10 @@ const std = @import("std"); -const adw = @import("adw"); const gobject = @import("gobject"); const gtk = @import("gtk"); const gresource = @import("../build/gresource.zig"); const i18n = @import("../../../os/main.zig").i18n; -const adw_version = @import("../adw_version.zig"); const Common = @import("../class.zig").Common; -const Config = @import("config.zig").Config; const Dialog = @import("dialog.zig").Dialog; const log = std.log.scoped(.gtk_ghostty_close_confirmation_dialog); diff --git a/src/apprt/gtk/class/config.zig b/src/apprt/gtk/class/config.zig index eadd3b7b8..9a705d356 100644 --- a/src/apprt/gtk/class/config.zig +++ b/src/apprt/gtk/class/config.zig @@ -1,7 +1,5 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const adw = @import("adw"); -const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); diff --git a/src/apprt/gtk/class/config_errors_dialog.zig b/src/apprt/gtk/class/config_errors_dialog.zig index fc76bc268..46d5fe621 100644 --- a/src/apprt/gtk/class/config_errors_dialog.zig +++ b/src/apprt/gtk/class/config_errors_dialog.zig @@ -1,10 +1,8 @@ const std = @import("std"); -const adw = @import("adw"); const gobject = @import("gobject"); const gtk = @import("gtk"); const gresource = @import("../build/gresource.zig"); -const adw_version = @import("../adw_version.zig"); const Common = @import("../class.zig").Common; const Config = @import("config.zig").Config; const Dialog = @import("dialog.zig").Dialog; diff --git a/src/apprt/gtk/class/debug_warning.zig b/src/apprt/gtk/class/debug_warning.zig index edda6659b..0ad320337 100644 --- a/src/apprt/gtk/class/debug_warning.zig +++ b/src/apprt/gtk/class/debug_warning.zig @@ -1,9 +1,7 @@ -const std = @import("std"); const adw = @import("adw"); const gobject = @import("gobject"); const gtk = @import("gtk"); -const build_config = @import("../../../build_config.zig"); const adw_version = @import("../adw_version.zig"); const gresource = @import("../build/gresource.zig"); const Common = @import("../class.zig").Common; diff --git a/src/apprt/gtk/class/dialog.zig b/src/apprt/gtk/class/dialog.zig index 41a1988ba..5bc3cdfa5 100644 --- a/src/apprt/gtk/class/dialog.zig +++ b/src/apprt/gtk/class/dialog.zig @@ -3,10 +3,8 @@ const adw = @import("adw"); const gobject = @import("gobject"); const gtk = @import("gtk"); -const gresource = @import("../build/gresource.zig"); const adw_version = @import("../adw_version.zig"); const Common = @import("../class.zig").Common; -const Config = @import("config.zig").Config; const log = std.log.scoped(.gtk_ghostty_dialog); diff --git a/src/apprt/gtk/class/global_shortcuts.zig b/src/apprt/gtk/class/global_shortcuts.zig index e5d89003a..57652916a 100644 --- a/src/apprt/gtk/class/global_shortcuts.zig +++ b/src/apprt/gtk/class/global_shortcuts.zig @@ -1,14 +1,11 @@ const std = @import("std"); const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; -const adw = @import("adw"); const gio = @import("gio"); const glib = @import("glib"); const gobject = @import("gobject"); -const gtk = @import("gtk"); const Binding = @import("../../../input.zig").Binding; -const gresource = @import("../build/gresource.zig"); const key = @import("../key.zig"); const Common = @import("../class.zig").Common; const Application = @import("application.zig").Application; diff --git a/src/apprt/gtk/class/inspector_widget.zig b/src/apprt/gtk/class/inspector_widget.zig index 4321dcd57..046cd2174 100644 --- a/src/apprt/gtk/class/inspector_widget.zig +++ b/src/apprt/gtk/class/inspector_widget.zig @@ -1,6 +1,5 @@ const std = @import("std"); -const adw = @import("adw"); const gobject = @import("gobject"); const gtk = @import("gtk"); diff --git a/src/apprt/gtk/class/inspector_window.zig b/src/apprt/gtk/class/inspector_window.zig index 701718229..739e75691 100644 --- a/src/apprt/gtk/class/inspector_window.zig +++ b/src/apprt/gtk/class/inspector_window.zig @@ -2,15 +2,12 @@ const std = @import("std"); const build_config = @import("../../../build_config.zig"); const adw = @import("adw"); -const gdk = @import("gdk"); const gobject = @import("gobject"); const gtk = @import("gtk"); const gresource = @import("../build/gresource.zig"); -const key = @import("../key.zig"); const Common = @import("../class.zig").Common; -const Application = @import("application.zig").Application; const Surface = @import("surface.zig").Surface; const DebugWarning = @import("debug_warning.zig").DebugWarning; const InspectorWidget = @import("inspector_widget.zig").InspectorWidget; diff --git a/src/apprt/gtk/class/resize_overlay.zig b/src/apprt/gtk/class/resize_overlay.zig index e13dcbc5d..e14f15636 100644 --- a/src/apprt/gtk/class/resize_overlay.zig +++ b/src/apprt/gtk/class/resize_overlay.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = @import("../../../quirks.zig").inlineAssert; const adw = @import("adw"); const glib = @import("glib"); const gobject = @import("gobject"); diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 4fbf7a0c2..48656c951 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const build_config = @import("../../../build_config.zig"); const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const adw = @import("adw"); @@ -8,17 +7,11 @@ const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); -const i18n = @import("../../../os/main.zig").i18n; const apprt = @import("../../../apprt.zig"); -const input = @import("../../../input.zig"); -const CoreSurface = @import("../../../Surface.zig"); -const gtk_version = @import("../gtk_version.zig"); -const adw_version = @import("../adw_version.zig"); const ext = @import("../ext.zig"); const gresource = @import("../build/gresource.zig"); const Common = @import("../class.zig").Common; const WeakRef = @import("../weak_ref.zig").WeakRef; -const Config = @import("config.zig").Config; const Application = @import("application.zig").Application; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; const Surface = @import("surface.zig").Surface; diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 6dae08a79..9ba7ce0ab 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -19,7 +19,6 @@ const terminal = @import("../../../terminal/main.zig"); const CoreSurface = @import("../../../Surface.zig"); const gresource = @import("../build/gresource.zig"); const ext = @import("../ext.zig"); -const adw_version = @import("../adw_version.zig"); const gtk_key = @import("../key.zig"); const ApprtSurface = @import("../Surface.zig"); const Common = @import("../class.zig").Common; @@ -30,7 +29,6 @@ const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited; const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog; const TitleDialog = @import("surface_title_dialog.zig").SurfaceTitleDialog; const Window = @import("window.zig").Window; -const WeakRef = @import("../weak_ref.zig").WeakRef; const InspectorWindow = @import("inspector_window.zig").InspectorWindow; const i18n = @import("../../../os/i18n.zig"); diff --git a/src/apprt/gtk/class/surface_child_exited.zig b/src/apprt/gtk/class/surface_child_exited.zig index 4e34f3340..d7dd41bcb 100644 --- a/src/apprt/gtk/class/surface_child_exited.zig +++ b/src/apprt/gtk/class/surface_child_exited.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = @import("../../../quirks.zig").inlineAssert; const adw = @import("adw"); const glib = @import("glib"); const gobject = @import("gobject"); diff --git a/src/apprt/gtk/class/surface_scrolled_window.zig b/src/apprt/gtk/class/surface_scrolled_window.zig index 505b16dda..488fdb3f4 100644 --- a/src/apprt/gtk/class/surface_scrolled_window.zig +++ b/src/apprt/gtk/class/surface_scrolled_window.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = @import("../../../quirks.zig").inlineAssert; const adw = @import("adw"); const gobject = @import("gobject"); const gtk = @import("gtk"); diff --git a/src/apprt/gtk/class/surface_title_dialog.zig b/src/apprt/gtk/class/surface_title_dialog.zig index 6d3bf33de..aa1d1a153 100644 --- a/src/apprt/gtk/class/surface_title_dialog.zig +++ b/src/apprt/gtk/class/surface_title_dialog.zig @@ -6,7 +6,6 @@ const gobject = @import("gobject"); const gtk = @import("gtk"); const gresource = @import("../build/gresource.zig"); -const adw_version = @import("../adw_version.zig"); const ext = @import("../ext.zig"); const Common = @import("../class.zig").Common; diff --git a/src/apprt/gtk/class/tab.zig b/src/apprt/gtk/class/tab.zig index d7a82b776..c8b5607a6 100644 --- a/src/apprt/gtk/class/tab.zig +++ b/src/apprt/gtk/class/tab.zig @@ -1,19 +1,13 @@ const std = @import("std"); -const build_config = @import("../../../build_config.zig"); -const assert = @import("../../../quirks.zig").inlineAssert; const adw = @import("adw"); const gio = @import("gio"); const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); -const i18n = @import("../../../os/main.zig").i18n; const apprt = @import("../../../apprt.zig"); -const input = @import("../../../input.zig"); const CoreSurface = @import("../../../Surface.zig"); const ext = @import("../ext.zig"); -const gtk_version = @import("../gtk_version.zig"); -const adw_version = @import("../adw_version.zig"); const gresource = @import("../build/gresource.zig"); const Common = @import("../class.zig").Common; const Config = @import("config.zig").Config; diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index dbcf0fcd1..c691b84a6 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -28,7 +28,6 @@ const Surface = @import("surface.zig").Surface; const Tab = @import("tab.zig").Tab; const DebugWarning = @import("debug_warning.zig").DebugWarning; const CommandPalette = @import("command_palette.zig").CommandPalette; -const InspectorWindow = @import("inspector_window.zig").InspectorWindow; const WeakRef = @import("../weak_ref.zig").WeakRef; const log = std.log.scoped(.gtk_ghostty_window); diff --git a/src/apprt/gtk/ext.zig b/src/apprt/gtk/ext.zig index f832d1f90..9b1eeecc6 100644 --- a/src/apprt/gtk/ext.zig +++ b/src/apprt/gtk/ext.zig @@ -7,7 +7,6 @@ const std = @import("std"); const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; -const gio = @import("gio"); const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index bf0f0e2f6..19bdc8315 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const build_options = @import("build_options"); const gdk = @import("gdk"); const glib = @import("glib"); diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index 5837e3e5e..ec02fbee5 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -1,7 +1,6 @@ //! Wayland protocol implementation for the Ghostty GTK apprt. const std = @import("std"); const Allocator = std.mem.Allocator; -const build_options = @import("build_options"); const gdk = @import("gdk"); const gdk_wayland = @import("gdk_wayland"); diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index 8956a29ed..9dc273563 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -1,10 +1,8 @@ //! X11 window protocol implementation for the Ghostty GTK apprt. const std = @import("std"); const builtin = @import("builtin"); -const build_options = @import("build_options"); const Allocator = std.mem.Allocator; -const adw = @import("adw"); const gdk = @import("gdk"); const gdk_x11 = @import("gdk_x11"); const glib = @import("glib"); diff --git a/src/benchmark/IsSymbol.zig b/src/benchmark/IsSymbol.zig index 5ba2da907..4fbffd1ec 100644 --- a/src/benchmark/IsSymbol.zig +++ b/src/benchmark/IsSymbol.zig @@ -4,7 +4,6 @@ const IsSymbol = @This(); const std = @import("std"); -const builtin = @import("builtin"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; const Benchmark = @import("Benchmark.zig"); diff --git a/src/build/GhosttyBench.zig b/src/build/GhosttyBench.zig index c9cd5dd33..27dda8809 100644 --- a/src/build/GhosttyBench.zig +++ b/src/build/GhosttyBench.zig @@ -2,7 +2,6 @@ const GhosttyBench = @This(); const std = @import("std"); -const Config = @import("Config.zig"); const SharedDeps = @import("SharedDeps.zig"); steps: []*std.Build.Step.Compile, diff --git a/src/build/GhosttyFrameData.zig b/src/build/GhosttyFrameData.zig index 7193162bd..8469759f9 100644 --- a/src/build/GhosttyFrameData.zig +++ b/src/build/GhosttyFrameData.zig @@ -3,8 +3,6 @@ const GhosttyFrameData = @This(); const std = @import("std"); -const Config = @import("Config.zig"); -const SharedDeps = @import("SharedDeps.zig"); const DistResource = @import("GhosttyDist.zig").Resource; /// The output path for the compressed framedata zig file diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index d1ab5d1ba..aae8ace19 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -3,11 +3,7 @@ const GhosttyLibVt = @This(); const std = @import("std"); const assert = std.debug.assert; const RunStep = std.Build.Step.Run; -const Config = @import("Config.zig"); const GhosttyZig = @import("GhosttyZig.zig"); -const SharedDeps = @import("SharedDeps.zig"); -const LibtoolStep = @import("LibtoolStep.zig"); -const LipoStep = @import("LipoStep.zig"); /// The step that generates the file. step: *std.Build.Step, diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index a1bbe2857..6f857655b 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -1,9 +1,7 @@ const GhosttyResources = @This(); const std = @import("std"); -const builtin = @import("builtin"); const assert = std.debug.assert; -const buildpkg = @import("main.zig"); const Config = @import("Config.zig"); const RunStep = std.Build.Step.Run; const SharedDeps = @import("SharedDeps.zig"); diff --git a/src/build/GhosttyWebdata.zig b/src/build/GhosttyWebdata.zig index 145bb91fa..e29b20c25 100644 --- a/src/build/GhosttyWebdata.zig +++ b/src/build/GhosttyWebdata.zig @@ -3,7 +3,6 @@ const GhosttyWebdata = @This(); const std = @import("std"); -const Config = @import("Config.zig"); const SharedDeps = @import("SharedDeps.zig"); steps: []*std.Build.Step, diff --git a/src/build/UnicodeTables.zig b/src/build/UnicodeTables.zig index aba3e8f24..17a839eaf 100644 --- a/src/build/UnicodeTables.zig +++ b/src/build/UnicodeTables.zig @@ -1,7 +1,6 @@ const UnicodeTables = @This(); const std = @import("std"); -const Config = @import("Config.zig"); /// The exe. props_exe: *std.Build.Step.Compile, diff --git a/src/build/webgen/main_actions.zig b/src/build/webgen/main_actions.zig index 85357b972..b0de6537d 100644 --- a/src/build/webgen/main_actions.zig +++ b/src/build/webgen/main_actions.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const help_strings = @import("help_strings"); const helpgen_actions = @import("../../input/helpgen_actions.zig"); pub fn main() !void { diff --git a/src/build_config.zig b/src/build_config.zig index 0d294c69e..c19f7372b 100644 --- a/src/build_config.zig +++ b/src/build_config.zig @@ -9,7 +9,6 @@ const assert = std.debug.assert; const apprt = @import("apprt.zig"); const font = @import("font/main.zig"); const rendererpkg = @import("renderer.zig"); -const WasmTarget = @import("os/wasm/target.zig").Target; const BuildConfig = @import("build/Config.zig"); pub const ReleaseChannel = BuildConfig.ReleaseChannel; diff --git a/src/cli/boo.zig b/src/cli/boo.zig index f96fd6282..2834eadbd 100644 --- a/src/cli/boo.zig +++ b/src/cli/boo.zig @@ -3,7 +3,6 @@ const builtin = @import("builtin"); const args = @import("args.zig"); const Action = @import("ghostty.zig").Action; const Allocator = std.mem.Allocator; -const help_strings = @import("help_strings"); const vaxis = @import("vaxis"); const framedata = @import("framedata").compressed; diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 63184ddfb..1e301eb73 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -1,11 +1,9 @@ const std = @import("std"); -const inputpkg = @import("../input.zig"); const args = @import("args.zig"); const Action = @import("ghostty.zig").Action; const Config = @import("../config/Config.zig"); const themepkg = @import("../config/theme.zig"); const tui = @import("tui.zig"); -const internal_os = @import("../os/main.zig"); const global_state = &@import("../global.zig").state; const vaxis = @import("vaxis"); diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig index 9434e9771..d3ee658af 100644 --- a/src/cli/ssh_cache.zig +++ b/src/cli/ssh_cache.zig @@ -1,7 +1,6 @@ const std = @import("std"); const fs = std.fs; const Allocator = std.mem.Allocator; -const xdg = @import("../os/xdg.zig"); const args = @import("args.zig"); const Action = @import("ghostty.zig").Action; pub const Entry = @import("ssh-cache/Entry.zig"); diff --git a/src/cli/validate_config.zig b/src/cli/validate_config.zig index 55d861402..5586cf29f 100644 --- a/src/cli/validate_config.zig +++ b/src/cli/validate_config.zig @@ -3,7 +3,6 @@ const Allocator = std.mem.Allocator; const args = @import("args.zig"); const Action = @import("ghostty.zig").Action; const Config = @import("../config.zig").Config; -const cli = @import("../cli.zig"); pub const Options = struct { /// The path of the config file to validate. If this isn't specified, diff --git a/src/config/CApi.zig b/src/config/CApi.zig index d3f714a45..a970a8d33 100644 --- a/src/config/CApi.zig +++ b/src/config/CApi.zig @@ -1,6 +1,4 @@ const std = @import("std"); -const assert = @import("../quirks.zig").inlineAssert; -const cli = @import("../cli.zig"); const inputpkg = @import("../input.zig"); const state = &@import("../global.zig").state; const c = @import("../main_c.zig"); diff --git a/src/config/Config.zig b/src/config/Config.zig index 18412ff0e..bac7d3443 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -29,8 +29,6 @@ const formatterpkg = @import("formatter.zig"); const themepkg = @import("theme.zig"); const url = @import("url.zig"); const Key = @import("key.zig").Key; -const KeyValue = @import("key.zig").Value; -const ErrorList = @import("ErrorList.zig"); const MetricModifier = fontpkg.Metrics.Modifier; const help_strings = @import("help_strings"); pub const Command = @import("command.zig").Command; diff --git a/src/config/command.zig b/src/config/command.zig index e0cdc641b..7e16ad5c7 100644 --- a/src/config/command.zig +++ b/src/config/command.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const formatterpkg = @import("formatter.zig"); diff --git a/src/config/conditional.zig b/src/config/conditional.zig index aabfeca1c..fdc285a22 100644 --- a/src/config/conditional.zig +++ b/src/config/conditional.zig @@ -1,6 +1,5 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; /// Conditionals in Ghostty configuration are based on a static, typed diff --git a/src/config/edit.zig b/src/config/edit.zig index 6c18abadc..8cedc47a5 100644 --- a/src/config/edit.zig +++ b/src/config/edit.zig @@ -3,7 +3,6 @@ const builtin = @import("builtin"); const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; -const internal_os = @import("../os/main.zig"); const file_load = @import("file_load.zig"); /// The path to the configuration that should be opened for editing. diff --git a/src/config/theme.zig b/src/config/theme.zig index 983ce647d..7ba6e5885 100644 --- a/src/config/theme.zig +++ b/src/config/theme.zig @@ -1,6 +1,5 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const global_state = &@import("../global.zig").state; const internal_os = @import("../os/main.zig"); diff --git a/src/datastruct/blocking_queue.zig b/src/datastruct/blocking_queue.zig index 339007c3a..3185d98d1 100644 --- a/src/datastruct/blocking_queue.zig +++ b/src/datastruct/blocking_queue.zig @@ -2,8 +2,6 @@ //! between threads. const std = @import("std"); -const builtin = @import("builtin"); -const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; /// Returns a blocking queue implementation for type T. diff --git a/src/extra/sublime.zig b/src/extra/sublime.zig index 4af589b4f..e0deb2fa9 100644 --- a/src/extra/sublime.zig +++ b/src/extra/sublime.zig @@ -1,4 +1,3 @@ -const std = @import("std"); const Config = @import("../config/Config.zig"); const Template = struct { diff --git a/src/font/Collection.zig b/src/font/Collection.zig index 6726fb64a..412098f10 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -16,7 +16,6 @@ const Collection = @This(); const std = @import("std"); -const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const config = @import("../config.zig"); const comparison = @import("../datastruct/comparison.zig"); diff --git a/src/font/DeferredFace.zig b/src/font/DeferredFace.zig index 61d0adf8b..e818cca30 100644 --- a/src/font/DeferredFace.zig +++ b/src/font/DeferredFace.zig @@ -7,7 +7,6 @@ const DeferredFace = @This(); const std = @import("std"); -const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const fontconfig = @import("fontconfig"); const macos = @import("macos"); diff --git a/src/font/discovery.zig b/src/font/discovery.zig index 45fc89ea9..c419d36a6 100644 --- a/src/font/discovery.zig +++ b/src/font/discovery.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const assert = @import("../quirks.zig").inlineAssert; const fontconfig = @import("fontconfig"); diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index fe3dcf707..a6ef52c39 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -16,7 +16,6 @@ const font = @import("../main.zig"); const Glyph = font.Glyph; const Library = font.Library; const opentype = @import("../opentype.zig"); -const fastmem = @import("../../fastmem.zig"); const quirks = @import("../../quirks.zig"); const config = @import("../../config.zig"); diff --git a/src/font/face/web_canvas.zig b/src/font/face/web_canvas.zig index d6a3ca449..b4f9f5d5d 100644 --- a/src/font/face/web_canvas.zig +++ b/src/font/face/web_canvas.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const builtin = @import("builtin"); const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; diff --git a/src/font/library.zig b/src/font/library.zig index 43aa101b7..dce6dbd5a 100644 --- a/src/font/library.zig +++ b/src/font/library.zig @@ -2,7 +2,6 @@ //! library implementation(s) require per-process. const std = @import("std"); const Allocator = std.mem.Allocator; -const builtin = @import("builtin"); const options = @import("main.zig").options; const freetype = @import("freetype"); const font = @import("main.zig"); diff --git a/src/font/opentype/head.zig b/src/font/opentype/head.zig index 69b951821..38284d9cf 100644 --- a/src/font/opentype/head.zig +++ b/src/font/opentype/head.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = @import("../../quirks.zig").inlineAssert; const sfnt = @import("sfnt.zig"); /// Font Header Table diff --git a/src/font/opentype/hhea.zig b/src/font/opentype/hhea.zig index 2a86e5b82..b2b3f3e20 100644 --- a/src/font/opentype/hhea.zig +++ b/src/font/opentype/hhea.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = @import("../../quirks.zig").inlineAssert; const sfnt = @import("sfnt.zig"); /// Horizontal Header Table diff --git a/src/font/opentype/os2.zig b/src/font/opentype/os2.zig index 9bcec973d..1cd11f35e 100644 --- a/src/font/opentype/os2.zig +++ b/src/font/opentype/os2.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = @import("../../quirks.zig").inlineAssert; const sfnt = @import("sfnt.zig"); pub const FSSelection = packed struct(sfnt.uint16) { diff --git a/src/font/opentype/post.zig b/src/font/opentype/post.zig index b739bd224..8031a0a4d 100644 --- a/src/font/opentype/post.zig +++ b/src/font/opentype/post.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = @import("../../quirks.zig").inlineAssert; const sfnt = @import("sfnt.zig"); /// PostScript Table diff --git a/src/font/opentype/svg.zig b/src/font/opentype/svg.zig index 348a1dc5b..b4d9ccaa2 100644 --- a/src/font/opentype/svg.zig +++ b/src/font/opentype/svg.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = @import("../../quirks.zig").inlineAssert; const font = @import("../main.zig"); /// SVG glyphs description table. diff --git a/src/font/shape.zig b/src/font/shape.zig index 0d8a029bf..c96c8df7f 100644 --- a/src/font/shape.zig +++ b/src/font/shape.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const builtin = @import("builtin"); const options = @import("main.zig").options; const run = @import("shaper/run.zig"); const feature = @import("shaper/feature.zig"); diff --git a/src/font/shaper/Cache.zig b/src/font/shaper/Cache.zig index 70b49bb75..2696985a4 100644 --- a/src/font/shaper/Cache.zig +++ b/src/font/shaper/Cache.zig @@ -11,7 +11,6 @@ pub const Cache = @This(); const std = @import("std"); -const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const font = @import("../main.zig"); const CacheTable = @import("../../datastruct/main.zig").CacheTable; diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index c1deec11d..97cb5cd89 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -3,11 +3,9 @@ const builtin = @import("builtin"); const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const macos = @import("macos"); -const trace = @import("tracy").trace; const font = @import("../main.zig"); const os = @import("../../os/main.zig"); const terminal = @import("../../terminal/main.zig"); -const config = @import("../../config.zig"); const Feature = font.shape.Feature; const FeatureList = font.shape.FeatureList; const default_features = font.shape.default_features; diff --git a/src/font/shaper/feature.zig b/src/font/shaper/feature.zig index b85d2867d..5bd73f97f 100644 --- a/src/font/shaper/feature.zig +++ b/src/font/shaper/feature.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const builtin = @import("builtin"); const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 2911e1e77..e4a9301e8 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -4,7 +4,6 @@ const Allocator = std.mem.Allocator; const harfbuzz = @import("harfbuzz"); const font = @import("../main.zig"); const terminal = @import("../../terminal/main.zig"); -const config = @import("../../config.zig"); const Feature = font.shape.Feature; const FeatureList = font.shape.FeatureList; const default_features = font.shape.default_features; diff --git a/src/font/shaper/noop.zig b/src/font/shaper/noop.zig index 5d2b1f54f..e5a08653f 100644 --- a/src/font/shaper/noop.zig +++ b/src/font/shaper/noop.zig @@ -1,7 +1,5 @@ const std = @import("std"); -const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; -const trace = @import("tracy").trace; const font = @import("../main.zig"); const Face = font.Face; const Collection = font.Collection; diff --git a/src/font/sprite/Face.zig b/src/font/sprite/Face.zig index a1f87f889..94bfa2f0b 100644 --- a/src/font/sprite/Face.zig +++ b/src/font/sprite/Face.zig @@ -13,7 +13,6 @@ const Face = @This(); const std = @import("std"); -const builtin = @import("builtin"); const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const wuffs = @import("wuffs"); diff --git a/src/font/sprite/draw/block.zig b/src/font/sprite/draw/block.zig index 96910ce57..1731d2f50 100644 --- a/src/font/sprite/draw/block.zig +++ b/src/font/sprite/draw/block.zig @@ -6,11 +6,8 @@ //! const std = @import("std"); -const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; -const z2d = @import("z2d"); - const common = @import("common.zig"); const Shade = common.Shade; const Quads = common.Quads; @@ -18,7 +15,6 @@ const Alignment = common.Alignment; const fill = common.fill; const font = @import("../../main.zig"); -const Sprite = @import("../../sprite.zig").Sprite; // Utility names for common fractions const one_eighth: f64 = 0.125; diff --git a/src/font/sprite/draw/box.zig b/src/font/sprite/draw/box.zig index ff6fa292e..cc6e694d4 100644 --- a/src/font/sprite/draw/box.zig +++ b/src/font/sprite/draw/box.zig @@ -15,8 +15,6 @@ const std = @import("std"); const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; -const z2d = @import("z2d"); - const common = @import("common.zig"); const Thickness = common.Thickness; const Shade = common.Shade; @@ -30,7 +28,6 @@ const hlineMiddle = common.hlineMiddle; const vlineMiddle = common.vlineMiddle; const font = @import("../../main.zig"); -const Sprite = @import("../../sprite.zig").Sprite; /// Specification of a traditional intersection-style line/box-drawing char, /// which can have a different style of line from each edge to the center. diff --git a/src/font/sprite/draw/branch.zig b/src/font/sprite/draw/branch.zig index 3cca6b7ff..034f1e398 100644 --- a/src/font/sprite/draw/branch.zig +++ b/src/font/sprite/draw/branch.zig @@ -16,7 +16,6 @@ //! const std = @import("std"); -const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const common = @import("common.zig"); diff --git a/src/font/sprite/draw/common.zig b/src/font/sprite/draw/common.zig index 18efe6c65..290c44965 100644 --- a/src/font/sprite/draw/common.zig +++ b/src/font/sprite/draw/common.zig @@ -4,13 +4,9 @@ //! rather than being single-use. const std = @import("std"); -const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; -const z2d = @import("z2d"); - const font = @import("../../main.zig"); -const Sprite = @import("../../sprite.zig").Sprite; const log = std.log.scoped(.sprite_font); diff --git a/src/font/sprite/draw/geometric_shapes.zig b/src/font/sprite/draw/geometric_shapes.zig index d95a4fd2f..f6402cf05 100644 --- a/src/font/sprite/draw/geometric_shapes.zig +++ b/src/font/sprite/draw/geometric_shapes.zig @@ -15,8 +15,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const z2d = @import("z2d"); - const common = @import("common.zig"); const Thickness = common.Thickness; const Corner = common.Corner; diff --git a/src/font/sprite/draw/powerline.zig b/src/font/sprite/draw/powerline.zig index 24fce454b..8658d8553 100644 --- a/src/font/sprite/draw/powerline.zig +++ b/src/font/sprite/draw/powerline.zig @@ -11,8 +11,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const z2d = @import("z2d"); - const common = @import("common.zig"); const Thickness = common.Thickness; const Shade = common.Shade; diff --git a/src/font/sprite/draw/special.zig b/src/font/sprite/draw/special.zig index 22d8edb5c..8cad9ceba 100644 --- a/src/font/sprite/draw/special.zig +++ b/src/font/sprite/draw/special.zig @@ -6,8 +6,6 @@ //! having names that exactly match the enum fields in Sprite. const std = @import("std"); -const builtin = @import("builtin"); -const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const font = @import("../../main.zig"); const Sprite = font.sprite.Sprite; diff --git a/src/font/sprite/draw/symbols_for_legacy_computing.zig b/src/font/sprite/draw/symbols_for_legacy_computing.zig index 7abc179fe..d99fc8702 100644 --- a/src/font/sprite/draw/symbols_for_legacy_computing.zig +++ b/src/font/sprite/draw/symbols_for_legacy_computing.zig @@ -23,8 +23,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const assert = @import("../../../quirks.zig").inlineAssert; -const z2d = @import("z2d"); - const common = @import("common.zig"); const Thickness = common.Thickness; const Alignment = common.Alignment; diff --git a/src/font/sprite/draw/symbols_for_legacy_computing_supplement.zig b/src/font/sprite/draw/symbols_for_legacy_computing_supplement.zig index 45148ee76..bd91d3925 100644 --- a/src/font/sprite/draw/symbols_for_legacy_computing_supplement.zig +++ b/src/font/sprite/draw/symbols_for_legacy_computing_supplement.zig @@ -51,8 +51,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const assert = @import("../../../quirks.zig").inlineAssert; -const z2d = @import("z2d"); - const common = @import("common.zig"); const Thickness = common.Thickness; const Fraction = common.Fraction; diff --git a/src/input/KeymapDarwin.zig b/src/input/KeymapDarwin.zig index 53c305ab1..a8702730e 100644 --- a/src/input/KeymapDarwin.zig +++ b/src/input/KeymapDarwin.zig @@ -17,7 +17,6 @@ const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const macos = @import("macos"); const codes = @import("keycodes.zig").entries; -const Key = @import("key.zig").Key; const Mods = @import("key.zig").Mods; /// The current input source that is selected for the keyboard. This can diff --git a/src/input/command.zig b/src/input/command.zig index 3879efc36..72fb7f4ee 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const builtin = @import("builtin"); const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const Action = @import("Binding.zig").Action; diff --git a/src/input/kitty.zig b/src/input/kitty.zig index 7ebbd7757..e5789cc40 100644 --- a/src/input/kitty.zig +++ b/src/input/kitty.zig @@ -1,4 +1,3 @@ -const std = @import("std"); const key = @import("key.zig"); /// A single entry in the kitty keymap data. There are only ~100 entries diff --git a/src/input/paste.zig b/src/input/paste.zig index 197386e89..111a783f3 100644 --- a/src/input/paste.zig +++ b/src/input/paste.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = @import("../quirks.zig").inlineAssert; const Terminal = @import("../terminal/Terminal.zig"); pub const Options = struct { diff --git a/src/inspector/cursor.zig b/src/inspector/cursor.zig index 37ec412e9..756898252 100644 --- a/src/inspector/cursor.zig +++ b/src/inspector/cursor.zig @@ -1,4 +1,3 @@ -const std = @import("std"); const cimgui = @import("cimgui"); const terminal = @import("../terminal/main.zig"); diff --git a/src/inspector/page.zig b/src/inspector/page.zig index 2cc62772e..7da469e21 100644 --- a/src/inspector/page.zig +++ b/src/inspector/page.zig @@ -1,9 +1,7 @@ const std = @import("std"); -const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const cimgui = @import("cimgui"); const terminal = @import("../terminal/main.zig"); -const inspector = @import("main.zig"); const units = @import("units.zig"); pub fn render(page: *const terminal.Page) void { diff --git a/src/lib/union.zig b/src/lib/union.zig index c1513fc79..924d0e864 100644 --- a/src/lib/union.zig +++ b/src/lib/union.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; const Target = @import("target.zig").Target; diff --git a/src/main_bench.zig b/src/main_bench.zig index 2314dc2ed..9804f51ef 100644 --- a/src/main_bench.zig +++ b/src/main_bench.zig @@ -1,5 +1,3 @@ -const std = @import("std"); -const builtin = @import("builtin"); const benchmark = @import("benchmark/main.zig"); pub const main = benchmark.cli.main; diff --git a/src/main_gen.zig b/src/main_gen.zig index b988819f8..3342bc2e9 100644 --- a/src/main_gen.zig +++ b/src/main_gen.zig @@ -1,5 +1,3 @@ -const std = @import("std"); -const builtin = @import("builtin"); const synthetic = @import("synthetic/main.zig"); pub const main = synthetic.cli.main; diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 77b7f3ef4..261e0ad7d 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -6,14 +6,8 @@ const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const posix = std.posix; const build_config = @import("build_config.zig"); -const options = @import("build_options"); -const glslang = @import("glslang"); const macos = @import("macos"); -const oni = @import("oniguruma"); const cli = @import("cli.zig"); -const internal_os = @import("os/main.zig"); -const fontconfig = @import("fontconfig"); -const harfbuzz = @import("harfbuzz"); const renderer = @import("renderer.zig"); const apprt = @import("apprt.zig"); diff --git a/src/os/TempDir.zig b/src/os/TempDir.zig index f2e9992c4..2ddf18da3 100644 --- a/src/os/TempDir.zig +++ b/src/os/TempDir.zig @@ -3,7 +3,6 @@ const TempDir = @This(); const std = @import("std"); -const builtin = @import("builtin"); const testing = std.testing; const Dir = std.fs.Dir; const allocTmpDir = @import("file.zig").allocTmpDir; diff --git a/src/os/args.zig b/src/os/args.zig index 871663504..9ef5bba40 100644 --- a/src/os/args.zig +++ b/src/os/args.zig @@ -1,6 +1,5 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const objc = @import("objc"); const macos = @import("macos"); diff --git a/src/os/flatpak.zig b/src/os/flatpak.zig index 1b517cd83..78692089e 100644 --- a/src/os/flatpak.zig +++ b/src/os/flatpak.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const posix = std.posix; diff --git a/src/os/homedir.zig b/src/os/homedir.zig index 28b4a0f73..0868a4fa5 100644 --- a/src/os/homedir.zig +++ b/src/os/homedir.zig @@ -1,6 +1,5 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = @import("../quirks.zig").inlineAssert; const passwd = @import("passwd.zig"); const posix = std.posix; const objc = @import("objc"); diff --git a/src/os/mouse.zig b/src/os/mouse.zig index b592bd94a..d68bb226f 100644 --- a/src/os/mouse.zig +++ b/src/os/mouse.zig @@ -1,6 +1,5 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = @import("../quirks.zig").inlineAssert; const objc = @import("objc"); const log = std.log.scoped(.os); diff --git a/src/os/wasm/log.zig b/src/os/wasm/log.zig index 1aac8c4e7..faa885c6e 100644 --- a/src/os/wasm/log.zig +++ b/src/os/wasm/log.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const builtin = @import("builtin"); const wasm = @import("../wasm.zig"); // Use the correct implementation diff --git a/src/os/xdg.zig b/src/os/xdg.zig index 57ef075aa..a813b0a98 100644 --- a/src/os/xdg.zig +++ b/src/os/xdg.zig @@ -3,7 +3,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const posix = std.posix; const homedir = @import("homedir.zig"); diff --git a/src/renderer.zig b/src/renderer.zig index f09f717c4..2d37ddd4c 100644 --- a/src/renderer.zig +++ b/src/renderer.zig @@ -7,8 +7,6 @@ //! APIs. The renderers in this package assume that the renderer is already //! setup (OpenGL has a context, Vulkan has a surface, etc.) -const std = @import("std"); -const builtin = @import("builtin"); const build_config = @import("build_config.zig"); const cursor = @import("renderer/cursor.zig"); diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index efd98601c..da577f957 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -2,7 +2,6 @@ pub const OpenGL = @This(); const std = @import("std"); -const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const gl = @import("opengl"); diff --git a/src/renderer/Options.zig b/src/renderer/Options.zig index 85ff8e310..948b31d2d 100644 --- a/src/renderer/Options.zig +++ b/src/renderer/Options.zig @@ -3,7 +3,6 @@ const apprt = @import("../apprt.zig"); const font = @import("../font/main.zig"); const renderer = @import("../renderer.zig"); -const Config = @import("../config.zig").Config; /// The derived configuration for this renderer implementation. config: renderer.Renderer.DerivedConfig, diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 7316ac51d..c1b377b3d 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -4,7 +4,6 @@ pub const Thread = @This(); const std = @import("std"); const builtin = @import("builtin"); -const assert = @import("../quirks.zig").inlineAssert; const xev = @import("../global.zig").xev; const crash = @import("../crash/main.zig"); const internal_os = @import("../os/main.zig"); diff --git a/src/renderer/link.zig b/src/renderer/link.zig index 8c09a3195..74df3e596 100644 --- a/src/renderer/link.zig +++ b/src/renderer/link.zig @@ -2,7 +2,6 @@ const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; const oni = @import("oniguruma"); -const configpkg = @import("../config.zig"); const inputpkg = @import("../input.zig"); const terminal = @import("../terminal/main.zig"); const point = terminal.point; diff --git a/src/renderer/message.zig b/src/renderer/message.zig index 8d4db32cd..a47b96080 100644 --- a/src/renderer/message.zig +++ b/src/renderer/message.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const configpkg = @import("../config.zig"); diff --git a/src/renderer/metal/Frame.zig b/src/renderer/metal/Frame.zig index e919a01ed..388b4f9ed 100644 --- a/src/renderer/metal/Frame.zig +++ b/src/renderer/metal/Frame.zig @@ -3,17 +3,13 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = @import("../../quirks.zig").inlineAssert; -const builtin = @import("builtin"); const objc = @import("objc"); const mtl = @import("api.zig"); const Renderer = @import("../generic.zig").Renderer(Metal); const Metal = @import("../Metal.zig"); const Target = @import("Target.zig"); -const Pipeline = @import("Pipeline.zig"); const RenderPass = @import("RenderPass.zig"); -const Buffer = @import("buffer.zig").Buffer; const Health = @import("../../renderer.zig").Health; diff --git a/src/renderer/metal/IOSurfaceLayer.zig b/src/renderer/metal/IOSurfaceLayer.zig index afee0953f..34fbfbed5 100644 --- a/src/renderer/metal/IOSurfaceLayer.zig +++ b/src/renderer/metal/IOSurfaceLayer.zig @@ -4,8 +4,6 @@ const IOSurfaceLayer = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = @import("../../quirks.zig").inlineAssert; -const builtin = @import("builtin"); const objc = @import("objc"); const macos = @import("macos"); diff --git a/src/renderer/metal/Pipeline.zig b/src/renderer/metal/Pipeline.zig index cf495edda..9ba25c350 100644 --- a/src/renderer/metal/Pipeline.zig +++ b/src/renderer/metal/Pipeline.zig @@ -3,14 +3,10 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = @import("../../quirks.zig").inlineAssert; -const builtin = @import("builtin"); const macos = @import("macos"); const objc = @import("objc"); const mtl = @import("api.zig"); -const Texture = @import("Texture.zig"); -const Metal = @import("../Metal.zig"); const log = std.log.scoped(.metal); diff --git a/src/renderer/metal/RenderPass.zig b/src/renderer/metal/RenderPass.zig index eb458e054..f204e1770 100644 --- a/src/renderer/metal/RenderPass.zig +++ b/src/renderer/metal/RenderPass.zig @@ -3,8 +3,6 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = @import("../../quirks.zig").inlineAssert; -const builtin = @import("builtin"); const objc = @import("objc"); const mtl = @import("api.zig"); @@ -12,8 +10,6 @@ const Pipeline = @import("Pipeline.zig"); const Sampler = @import("Sampler.zig"); const Texture = @import("Texture.zig"); const Target = @import("Target.zig"); -const Metal = @import("../Metal.zig"); -const Buffer = @import("buffer.zig").Buffer; const log = std.log.scoped(.metal); diff --git a/src/renderer/metal/Sampler.zig b/src/renderer/metal/Sampler.zig index d1069948e..593f9a864 100644 --- a/src/renderer/metal/Sampler.zig +++ b/src/renderer/metal/Sampler.zig @@ -3,8 +3,6 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = @import("../../quirks.zig").inlineAssert; -const builtin = @import("builtin"); const objc = @import("objc"); const mtl = @import("api.zig"); diff --git a/src/renderer/metal/Target.zig b/src/renderer/metal/Target.zig index fe572a63b..f20bb0b7c 100644 --- a/src/renderer/metal/Target.zig +++ b/src/renderer/metal/Target.zig @@ -5,8 +5,6 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = @import("../../quirks.zig").inlineAssert; -const builtin = @import("builtin"); const objc = @import("objc"); const macos = @import("macos"); const graphics = macos.graphics; diff --git a/src/renderer/metal/Texture.zig b/src/renderer/metal/Texture.zig index c339277e8..5042919ac 100644 --- a/src/renderer/metal/Texture.zig +++ b/src/renderer/metal/Texture.zig @@ -4,7 +4,6 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; const assert = @import("../../quirks.zig").inlineAssert; -const builtin = @import("builtin"); const objc = @import("objc"); const mtl = @import("api.zig"); diff --git a/src/renderer/metal/buffer.zig b/src/renderer/metal/buffer.zig index 8d2254640..f91f89e99 100644 --- a/src/renderer/metal/buffer.zig +++ b/src/renderer/metal/buffer.zig @@ -1,6 +1,5 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = @import("../../quirks.zig").inlineAssert; const objc = @import("objc"); const macos = @import("macos"); diff --git a/src/renderer/metal/shaders.zig b/src/renderer/metal/shaders.zig index 653c0dea2..0be023572 100644 --- a/src/renderer/metal/shaders.zig +++ b/src/renderer/metal/shaders.zig @@ -1,6 +1,5 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = @import("../../quirks.zig").inlineAssert; const macos = @import("macos"); const objc = @import("objc"); const math = @import("../../math.zig"); diff --git a/src/renderer/opengl/Frame.zig b/src/renderer/opengl/Frame.zig index 3d0efbdfb..289413b0a 100644 --- a/src/renderer/opengl/Frame.zig +++ b/src/renderer/opengl/Frame.zig @@ -3,16 +3,12 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = @import("../../quirks.zig").inlineAssert; -const builtin = @import("builtin"); const gl = @import("opengl"); const Renderer = @import("../generic.zig").Renderer(OpenGL); const OpenGL = @import("../OpenGL.zig"); const Target = @import("Target.zig"); -const Pipeline = @import("Pipeline.zig"); const RenderPass = @import("RenderPass.zig"); -const Buffer = @import("buffer.zig").Buffer; const Health = @import("../../renderer.zig").Health; diff --git a/src/renderer/opengl/Pipeline.zig b/src/renderer/opengl/Pipeline.zig index 04130752a..2469f45bc 100644 --- a/src/renderer/opengl/Pipeline.zig +++ b/src/renderer/opengl/Pipeline.zig @@ -3,14 +3,8 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = @import("../../quirks.zig").inlineAssert; -const builtin = @import("builtin"); const gl = @import("opengl"); -const OpenGL = @import("../OpenGL.zig"); -const Texture = @import("Texture.zig"); -const Buffer = @import("buffer.zig").Buffer; - const log = std.log.scoped(.opengl); /// Options for initializing a render pipeline. diff --git a/src/renderer/opengl/RenderPass.zig b/src/renderer/opengl/RenderPass.zig index 1ef151c45..180664942 100644 --- a/src/renderer/opengl/RenderPass.zig +++ b/src/renderer/opengl/RenderPass.zig @@ -3,16 +3,12 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = @import("../../quirks.zig").inlineAssert; -const builtin = @import("builtin"); const gl = @import("opengl"); -const OpenGL = @import("../OpenGL.zig"); const Sampler = @import("Sampler.zig"); const Target = @import("Target.zig"); const Texture = @import("Texture.zig"); const Pipeline = @import("Pipeline.zig"); -const RenderPass = @import("RenderPass.zig"); const Buffer = @import("buffer.zig").Buffer; /// Options for beginning a render pass. diff --git a/src/renderer/opengl/Sampler.zig b/src/renderer/opengl/Sampler.zig index 66f579221..f4013c686 100644 --- a/src/renderer/opengl/Sampler.zig +++ b/src/renderer/opengl/Sampler.zig @@ -3,8 +3,6 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = @import("../../quirks.zig").inlineAssert; -const builtin = @import("builtin"); const gl = @import("opengl"); const OpenGL = @import("../OpenGL.zig"); diff --git a/src/renderer/opengl/Target.zig b/src/renderer/opengl/Target.zig index e9de7216e..5c6d818f1 100644 --- a/src/renderer/opengl/Target.zig +++ b/src/renderer/opengl/Target.zig @@ -5,8 +5,6 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = @import("../../quirks.zig").inlineAssert; -const builtin = @import("builtin"); const gl = @import("opengl"); const log = std.log.scoped(.opengl); diff --git a/src/renderer/opengl/Texture.zig b/src/renderer/opengl/Texture.zig index 71018d941..c37ec6866 100644 --- a/src/renderer/opengl/Texture.zig +++ b/src/renderer/opengl/Texture.zig @@ -3,8 +3,6 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = @import("../../quirks.zig").inlineAssert; -const builtin = @import("builtin"); const gl = @import("opengl"); const OpenGL = @import("../OpenGL.zig"); diff --git a/src/renderer/opengl/buffer.zig b/src/renderer/opengl/buffer.zig index 17d34e500..f9cbbcebd 100644 --- a/src/renderer/opengl/buffer.zig +++ b/src/renderer/opengl/buffer.zig @@ -1,6 +1,5 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = @import("../../quirks.zig").inlineAssert; const gl = @import("opengl"); const OpenGL = @import("../OpenGL.zig"); diff --git a/src/renderer/row.zig b/src/renderer/row.zig index 157d22b54..933bb338b 100644 --- a/src/renderer/row.zig +++ b/src/renderer/row.zig @@ -1,4 +1,3 @@ -const std = @import("std"); const terminal = @import("../terminal/main.zig"); // TODO: Test neverExtendBg function diff --git a/src/renderer/shadertoy.zig b/src/renderer/shadertoy.zig index 38860932b..0d096c0fc 100644 --- a/src/renderer/shadertoy.zig +++ b/src/renderer/shadertoy.zig @@ -1,6 +1,5 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const glslang = @import("glslang"); diff --git a/src/simd/index_of.zig b/src/simd/index_of.zig index cea549b95..7bf053b0d 100644 --- a/src/simd/index_of.zig +++ b/src/simd/index_of.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const builtin = @import("builtin"); const options = @import("build_options"); extern "c" fn ghostty_simd_index_of( diff --git a/src/surface_mouse.zig b/src/surface_mouse.zig index a9702a8fe..691f1b23c 100644 --- a/src/surface_mouse.zig +++ b/src/surface_mouse.zig @@ -8,7 +8,6 @@ const SurfaceMouse = @This(); const std = @import("std"); const builtin = @import("builtin"); const input = @import("input.zig"); -const apprt = @import("apprt.zig"); const terminal = @import("terminal/main.zig"); const MouseShape = @import("terminal/mouse_shape.zig").MouseShape; diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 3673cf1f4..e7cb56da7 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -15,7 +15,6 @@ const point = @import("point.zig"); const pagepkg = @import("page.zig"); const stylepkg = @import("style.zig"); const size = @import("size.zig"); -const Selection = @import("Selection.zig"); const OffsetBuf = size.OffsetBuf; const Capacity = pagepkg.Capacity; const Page = pagepkg.Page; diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 69f7e859f..980906e49 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -5,8 +5,6 @@ const Parser = @This(); const std = @import("std"); -const builtin = @import("builtin"); -const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; const table = @import("parse_table.zig").table; const osc = @import("osc.zig"); diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 68919107b..6c9db6a8d 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -5,7 +5,6 @@ const Terminal = @This(); const std = @import("std"); const build_options = @import("terminal_options"); -const builtin = @import("builtin"); const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; diff --git a/src/terminal/apc.zig b/src/terminal/apc.zig index 0585c78ba..3ebacbbff 100644 --- a/src/terminal/apc.zig +++ b/src/terminal/apc.zig @@ -1,6 +1,5 @@ const std = @import("std"); const build_options = @import("terminal_options"); -const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const kitty_gfx = @import("kitty/graphics.zig"); diff --git a/src/terminal/c/key_encode.zig b/src/terminal/c/key_encode.zig index 1e0367829..063cd8df7 100644 --- a/src/terminal/c/key_encode.zig +++ b/src/terminal/c/key_encode.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const lib_alloc = @import("../../lib/allocator.zig"); const CAllocator = lib_alloc.Allocator; diff --git a/src/terminal/c/key_event.zig b/src/terminal/c/key_event.zig index 6608c84b1..748b8799c 100644 --- a/src/terminal/c/key_event.zig +++ b/src/terminal/c/key_event.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const lib_alloc = @import("../../lib/allocator.zig"); const CAllocator = lib_alloc.Allocator; diff --git a/src/terminal/c/osc.zig b/src/terminal/c/osc.zig index 9c6286e6a..c4cdaad3b 100644 --- a/src/terminal/c/osc.zig +++ b/src/terminal/c/osc.zig @@ -1,6 +1,4 @@ const std = @import("std"); -const assert = @import("../../quirks.zig").inlineAssert; -const builtin = @import("builtin"); const lib_alloc = @import("../../lib/allocator.zig"); const CAllocator = lib_alloc.Allocator; const osc = @import("../osc.zig"); diff --git a/src/terminal/c/sgr.zig b/src/terminal/c/sgr.zig index ec35ce608..53536417f 100644 --- a/src/terminal/c/sgr.zig +++ b/src/terminal/c/sgr.zig @@ -1,8 +1,6 @@ const std = @import("std"); -const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; -const builtin = @import("builtin"); const lib_alloc = @import("../../lib/allocator.zig"); const CAllocator = lib_alloc.Allocator; const sgr = @import("../sgr.zig"); diff --git a/src/terminal/hash_map.zig b/src/terminal/hash_map.zig index a9d081782..e06050605 100644 --- a/src/terminal/hash_map.zig +++ b/src/terminal/hash_map.zig @@ -31,7 +31,6 @@ //! bottleneck. const std = @import("std"); -const builtin = @import("builtin"); const assert = @import("../quirks.zig").inlineAssert; const autoHash = std.hash.autoHash; const math = std.math; diff --git a/src/terminal/highlight.zig b/src/terminal/highlight.zig index 4db5e31e7..582ef6f06 100644 --- a/src/terminal/highlight.zig +++ b/src/terminal/highlight.zig @@ -11,7 +11,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = @import("../quirks.zig").inlineAssert; const size = @import("size.zig"); const PageList = @import("PageList.zig"); const PageChunk = PageList.PageIterator.Chunk; diff --git a/src/terminal/hyperlink.zig b/src/terminal/hyperlink.zig index b60ed795b..975e6f30e 100644 --- a/src/terminal/hyperlink.zig +++ b/src/terminal/hyperlink.zig @@ -1,6 +1,5 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = @import("../quirks.zig").inlineAssert; const hash_map = @import("hash_map.zig"); const AutoOffsetHashMap = hash_map.AutoOffsetHashMap; const pagepkg = @import("page.zig"); diff --git a/src/terminal/kitty/color.zig b/src/terminal/kitty/color.zig index dface5723..deeabcfb7 100644 --- a/src/terminal/kitty/color.zig +++ b/src/terminal/kitty/color.zig @@ -1,6 +1,4 @@ const std = @import("std"); -const build_options = @import("terminal_options"); -const LibEnum = @import("../../lib/enum.zig").Enum; const terminal = @import("../main.zig"); const RGB = terminal.color.RGB; const Terminator = terminal.osc.Terminator; diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index b5f8ad61b..5b3ab915d 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -2,8 +2,6 @@ const std = @import("std"); const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; -const renderer = @import("../../renderer.zig"); -const point = @import("../point.zig"); const Terminal = @import("../Terminal.zig"); const command = @import("graphics_command.zig"); const image = @import("graphics_image.zig"); diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index d5e0735a6..d2877cfc2 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -7,7 +7,6 @@ const posix = std.posix; const fastmem = @import("../../fastmem.zig"); const command = @import("graphics_command.zig"); -const point = @import("../point.zig"); const PageList = @import("../PageList.zig"); const wuffs = @import("wuffs"); diff --git a/src/terminal/kitty/graphics_render.zig b/src/terminal/kitty/graphics_render.zig index 4db9d1ab1..946b537a8 100644 --- a/src/terminal/kitty/graphics_render.zig +++ b/src/terminal/kitty/graphics_render.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const terminal = @import("../main.zig"); diff --git a/src/terminal/main.zig b/src/terminal/main.zig index fc7584c1a..06c930014 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -1,10 +1,7 @@ -const builtin = @import("builtin"); - const charsets = @import("charsets.zig"); const stream = @import("stream.zig"); const ansi = @import("ansi.zig"); const csi = @import("csi.zig"); -const hyperlink = @import("hyperlink.zig"); const render = @import("render.zig"); const stream_readonly = @import("stream_readonly.zig"); const style = @import("style.zig"); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index bf40d2353..124ff2545 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -10,7 +10,6 @@ const fastmem = @import("../fastmem.zig"); const color = @import("color.zig"); const hyperlink = @import("hyperlink.zig"); const kitty = @import("kitty.zig"); -const sgr = @import("sgr.zig"); const stylepkg = @import("style.zig"); const Style = stylepkg.Style; const StyleId = stylepkg.Id; diff --git a/src/terminal/parse_table.zig b/src/terminal/parse_table.zig index 2c8ccf8fc..01bd569cb 100644 --- a/src/terminal/parse_table.zig +++ b/src/terminal/parse_table.zig @@ -10,7 +10,6 @@ //! const std = @import("std"); -const builtin = @import("builtin"); const parser = @import("Parser.zig"); const State = parser.State; const Action = parser.TransitionAction; diff --git a/src/terminal/point.zig b/src/terminal/point.zig index fb44aae88..5a3d4a6f8 100644 --- a/src/terminal/point.zig +++ b/src/terminal/point.zig @@ -1,6 +1,5 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = @import("../quirks.zig").inlineAssert; const size = @import("size.zig"); /// The possible reference locations for a point. When someone says "(42, 80)" diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig index 3d0dd469a..e67682ff5 100644 --- a/src/terminal/ref_counted_set.zig +++ b/src/terminal/ref_counted_set.zig @@ -5,8 +5,6 @@ const size = @import("size.zig"); const Offset = size.Offset; const OffsetBuf = size.OffsetBuf; -const fastmem = @import("../fastmem.zig"); - /// A reference counted set. /// /// This set is created with some capacity in mind. You can determine diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 8addd6ba9..8f2d73f16 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -21,8 +21,6 @@ const MessageData = @import("../../datastruct/main.zig").MessageData; const point = @import("../point.zig"); const FlattenedHighlight = @import("../highlight.zig").Flattened; const UntrackedHighlight = @import("../highlight.zig").Untracked; -const PageList = @import("../PageList.zig"); -const Screen = @import("../Screen.zig"); const ScreenSet = @import("../ScreenSet.zig"); const Selection = @import("../Selection.zig"); const Terminal = @import("../Terminal.zig"); diff --git a/src/terminal/search/active.zig b/src/terminal/search/active.zig index 2329c40b0..236f4c7a6 100644 --- a/src/terminal/search/active.zig +++ b/src/terminal/search/active.zig @@ -5,7 +5,6 @@ const point = @import("../point.zig"); const size = @import("../size.zig"); const FlattenedHighlight = @import("../highlight.zig").Flattened; const PageList = @import("../PageList.zig"); -const Selection = @import("../Selection.zig"); const SlidingWindow = @import("sliding_window.zig").SlidingWindow; const Terminal = @import("../Terminal.zig"); diff --git a/src/terminal/search/pagelist.zig b/src/terminal/search/pagelist.zig index 227bd03f9..4bfd241e7 100644 --- a/src/terminal/search/pagelist.zig +++ b/src/terminal/search/pagelist.zig @@ -1,8 +1,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; -const CircBuf = @import("../../datastruct/main.zig").CircBuf; const terminal = @import("../main.zig"); const point = terminal.point; const FlattenedHighlight = @import("../highlight.zig").Flattened; @@ -11,7 +9,6 @@ const PageList = terminal.PageList; const Pin = PageList.Pin; const Selection = terminal.Selection; const Screen = terminal.Screen; -const PageFormatter = @import("../formatter.zig").PageFormatter; const Terminal = @import("../Terminal.zig"); const SlidingWindow = @import("sliding_window.zig").SlidingWindow; diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 97784e97e..0ae7f8a1f 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -10,7 +10,6 @@ const TrackedHighlight = highlight.Tracked; const PageList = @import("../PageList.zig"); const Pin = PageList.Pin; const Screen = @import("../Screen.zig"); -const Selection = @import("../Selection.zig"); const Terminal = @import("../Terminal.zig"); const ActiveSearch = @import("active.zig").ActiveSearch; const PageListSearch = @import("pagelist.zig").PageListSearch; diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index 55eedb724..76deebcec 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -6,7 +6,6 @@ const point = @import("../point.zig"); const size = @import("../size.zig"); const FlattenedHighlight = @import("../highlight.zig").Flattened; const PageList = @import("../PageList.zig"); -const Selection = @import("../Selection.zig"); const SlidingWindow = @import("sliding_window.zig").SlidingWindow; const Terminal = @import("../Terminal.zig"); diff --git a/src/termio/Options.zig b/src/termio/Options.zig index 7484fd087..f41709f4a 100644 --- a/src/termio/Options.zig +++ b/src/termio/Options.zig @@ -1,10 +1,8 @@ //! The options that are used to configure a terminal IO implementation. -const builtin = @import("builtin"); const xev = @import("../global.zig").xev; const apprt = @import("../apprt.zig"); const renderer = @import("../renderer.zig"); -const Command = @import("../Command.zig"); const Config = @import("../config.zig").Config; const termio = @import("../termio.zig"); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index e54c7ca61..53df00433 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -5,27 +5,20 @@ pub const Termio = @This(); const std = @import("std"); -const builtin = @import("builtin"); -const build_config = @import("../build_config.zig"); const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const EnvMap = std.process.EnvMap; const posix = std.posix; const termio = @import("../termio.zig"); -const Command = @import("../Command.zig"); -const Pty = @import("../pty.zig").Pty; const StreamHandler = @import("stream_handler.zig").StreamHandler; const terminalpkg = @import("../terminal/main.zig"); -const terminfo = @import("../terminfo/main.zig"); const xev = @import("../global.zig").xev; const renderer = @import("../renderer.zig"); const apprt = @import("../apprt.zig"); -const fastmem = @import("../fastmem.zig"); const internal_os = @import("../os/main.zig"); const windows = internal_os.windows; const configpkg = @import("../config.zig"); -const shell_integration = @import("shell_integration.zig"); const log = std.log.scoped(.io_exec); diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index bb616e623..b111d5a52 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -19,7 +19,6 @@ const crash = @import("../crash/main.zig"); const internal_os = @import("../os/main.zig"); const termio = @import("../termio.zig"); const renderer = @import("../renderer.zig"); -const BlockingQueue = @import("../datastruct/main.zig").BlockingQueue; const Allocator = std.mem.Allocator; const log = std.log.scoped(.io_thread); diff --git a/src/termio/backend.zig b/src/termio/backend.zig index ebd170079..ae0e2004f 100644 --- a/src/termio/backend.zig +++ b/src/termio/backend.zig @@ -1,18 +1,9 @@ const std = @import("std"); -const builtin = @import("builtin"); -const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const posix = std.posix; -const xev = @import("../global.zig").xev; -const build_config = @import("../build_config.zig"); -const configpkg = @import("../config.zig"); -const internal_os = @import("../os/main.zig"); const renderer = @import("../renderer.zig"); -const shell_integration = @import("shell_integration.zig"); const terminal = @import("../terminal/main.zig"); const termio = @import("../termio.zig"); -const Command = @import("../Command.zig"); -const Pty = @import("../pty.zig").Pty; // The preallocation size for the write request pool. This should be big // enough to satisfy most write requests. It must be a power of 2. diff --git a/src/termio/mailbox.zig b/src/termio/mailbox.zig index e91033180..2725d0241 100644 --- a/src/termio/mailbox.zig +++ b/src/termio/mailbox.zig @@ -1,6 +1,4 @@ const std = @import("std"); -const builtin = @import("builtin"); -const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const xev = @import("../global.zig").xev; const renderer = @import("../renderer.zig"); diff --git a/src/termio/message.zig b/src/termio/message.zig index 23b9f2545..f78da2058 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -1,7 +1,5 @@ const std = @import("std"); -const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; -const apprt = @import("../apprt.zig"); const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); const termio = @import("../termio.zig"); From 94f88c8b54fbbd6a4686261f42f266878e8aaeea Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Fri, 28 Nov 2025 10:14:07 +0100 Subject: [PATCH 178/209] macOS: fix toggle_visibility behaviour with tabbed windows This fixes regression of #5690, which kind of comes from #9576. 05b42919d5ce53b51be25cc4f900ee3f00988259 (before #9576) has weird behaviours too, restored windows are not properly focused. With this pr, we only order `selectedWindow` front so we won't mess up with its selection state and the order of the tab group. --- macos/Sources/App/macOS/AppDelegate.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index da20c2124..192135c15 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -1184,10 +1184,19 @@ class AppDelegate: NSObject, // want to bring back these windows if we remove the toggle. // // We also ignore fullscreen windows because they don't hide anyways. - self.hiddenWindows = NSApp.windows.filter { + var visibleWindows = [Weak]() + NSApp.windows.filter { $0.isVisible && !$0.styleMask.contains(.fullScreen) - }.map { Weak($0) } + }.forEach { window in + // We only keep track of selectedWindow if it's in a tabGroup, + // so we can keep its selection state when restoring + let windowToHide = window.tabGroup?.selectedWindow ?? window + if !visibleWindows.contains(where: { $0.value === windowToHide }) { + visibleWindows.append(Weak(windowToHide)) + } + } + self.hiddenWindows = visibleWindows } func restore() { From c75bade8969401526e8a419a37821ba27fd779ca Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 28 Nov 2025 12:59:23 -0800 Subject: [PATCH 179/209] macos: `window-width/height` is accurate even with other widgets Fixes #2660 Rather than calculate our window frame size based on various chrome calculations, we now utilize SwiftUI layouts and view intrinsic content sizes with `setContentSize` to setup our content size ignoring all our other widgets. I'm sure there's some edge cases I'm missing here but this should be a whole lot more reliable on the whole. --- .../Terminal/TerminalController.swift | 156 ++++++++++-------- .../Features/Terminal/TerminalView.swift | 4 +- .../Extensions/NSWindow+Extension.swift | 16 ++ 3 files changed, 105 insertions(+), 71 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index e1a98e598..93a05b6b9 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -508,55 +508,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr window.syncAppearance(surfaceConfig) } - /// Returns the default size of the window. This is contextual based on the focused surface because - /// the focused surface may specify a different default size than others. - private var defaultSize: NSRect? { - guard let screen = window?.screen ?? NSScreen.main else { return nil } - - if derivedConfig.maximize { - return screen.visibleFrame - } else if let focusedSurface, - let initialSize = focusedSurface.initialSize { - // Get the current frame of the window - guard var frame = window?.frame else { return nil } - - // Calculate the chrome size (window size minus view size) - let chromeWidth = frame.size.width - focusedSurface.frame.size.width - let chromeHeight = frame.size.height - focusedSurface.frame.size.height - - // Calculate the new width and height, clamping to the screen's size - let newWidth = min(initialSize.width + chromeWidth, screen.visibleFrame.width) - let newHeight = min(initialSize.height + chromeHeight, screen.visibleFrame.height) - - // Update the frame size while keeping the window's position intact - frame.size.width = newWidth - frame.size.height = newHeight - - // Ensure the window doesn't go outside the screen boundaries - frame.origin.x = max(screen.frame.origin.x, min(frame.origin.x, screen.frame.maxX - newWidth)) - frame.origin.y = max(screen.frame.origin.y, min(frame.origin.y, screen.frame.maxY - newHeight)) - - return adjustForWindowPosition(frame: frame, on: screen) - } - - guard let initialFrame else { return nil } - guard var frame = window?.frame else { return nil } - - // Calculate the new width and height, clamping to the screen's size - let newWidth = min(initialFrame.size.width, screen.visibleFrame.width) - let newHeight = min(initialFrame.size.height, screen.visibleFrame.height) - - // Update the frame size while keeping the window's position intact - frame.size.width = newWidth - frame.size.height = newHeight - - // Ensure the window doesn't go outside the screen boundaries - frame.origin.x = max(screen.frame.origin.x, min(frame.origin.x, screen.frame.maxX - newWidth)) - frame.origin.y = max(screen.frame.origin.y, min(frame.origin.y, screen.frame.maxY - newHeight)) - - return adjustForWindowPosition(frame: frame, on: screen) - } - /// Adjusts the given frame for the configured window position. func adjustForWindowPosition(frame: NSRect, on screen: NSScreen) -> NSRect { guard let x = derivedConfig.windowPositionX else { return frame } @@ -922,9 +873,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr super.windowDidLoad() guard let window else { return } - // Store our initial frame so we can know our default later. - initialFrame = window.frame - // I copy this because we may change the source in the future but also because // I regularly audit our codebase for "ghostty.config" access because generally // you shouldn't use it. Its safe in this case because for a new window we should @@ -944,19 +892,38 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // If this is our first surface then our focused surface will be nil // so we force the focused surface to the leaf. focusedSurface = view - - if let defaultSize { - window.setFrame(defaultSize, display: true) - } } // Initialize our content view to the SwiftUI root window.contentView = NSHostingView(rootView: TerminalView( ghostty: self.ghostty, viewModel: self, - delegate: self + delegate: self, )) - + + // If we have a default size, we want to apply it. + if let defaultSize { + switch (defaultSize) { + case .frame: + // Frames can be applied immediately + defaultSize.apply(to: window) + + case .contentIntrinsicSize: + // Content intrinsic size requires a short delay so that AppKit + // can layout our SwiftUI views. + DispatchQueue.main.asyncAfter(deadline: .now() + .microseconds(10_000)) { [weak window] in + guard let window else { return } + defaultSize.apply(to: window) + } + } + } + + // Store our initial frame so we can know our default later. This MUST + // be after the defaultSize call above so that we don't re-apply our frame. + // Note: we probably want to set this on the first frame change or something + // so it respects cascade. + initialFrame = window.frame + // In various situations, macOS automatically tabs new windows. Ghostty handles // its own tabbing so we DONT want this behavior. This detects this scenario and undoes // it. @@ -1144,8 +1111,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } @IBAction func returnToDefaultSize(_ sender: Any?) { - guard let defaultSize else { return } - window?.setFrame(defaultSize, display: true) + guard let window, let defaultSize else { return } + defaultSize.apply(to: window) } @IBAction override func closeWindow(_ sender: Any?) { @@ -1421,19 +1388,68 @@ extension TerminalController { // If our window is already the default size or we don't have a // default size, then disable. - guard let defaultSize, - window.frame.size != .init( - width: defaultSize.size.width, - height: defaultSize.size.height - ) - else { - return false - } - - return true + return defaultSize?.isChanged(for: window) ?? false default: return super.validateMenuItem(item) } } } + +// MARK: Default Size + +extension TerminalController { + /// The possible default sizes for a terminal. The size can't purely be known as a + /// window frame because if we set `window-width/height` then it is based + /// on content size. + enum DefaultSize { + /// A frame, set with `window.setFrame` + case frame(NSRect) + + /// A content size, set with `window.setContentSize` + case contentIntrinsicSize + + func isChanged(for window: NSWindow) -> Bool { + switch self { + case .frame(let rect): + return window.frame != rect + case .contentIntrinsicSize: + guard let view = window.contentView else { + return false + } + + return view.frame.size != view.intrinsicContentSize + } + } + + func apply(to window: NSWindow) { + switch self { + case .frame(let rect): + window.setFrame(rect, display: true) + case .contentIntrinsicSize: + guard let size = window.contentView?.intrinsicContentSize else { + return + } + + window.setContentSize(size) + window.constrainToScreen() + } + } + } + + private var defaultSize: DefaultSize? { + if derivedConfig.maximize, let screen = window?.screen ?? NSScreen.main { + // Maximize takes priority, we take up the full screen we're on. + return .frame(screen.visibleFrame) + } else if focusedSurface?.initialSize != nil { + // Initial size as requested by the configuration (e.g. `window-width`) + // takes next priority. + return .contentIntrinsicSize + } else if let initialFrame { + // The initial frame we had when we started otherwise. + return .frame(initialFrame) + } else { + return nil + } + } +} diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 8c5955c7f..fd53a617b 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -45,7 +45,7 @@ struct TerminalView: View { // An optional delegate to receive information about terminal changes. weak var delegate: (any TerminalViewDelegate)? = nil - + // The most recently focused surface, equal to focusedSurface when // it is non-nil. @State private var lastFocusedSurface: Weak = .init() @@ -100,6 +100,8 @@ struct TerminalView: View { guard let size = newValue else { return } self.delegate?.cellSizeDidChange(to: size) } + .frame(idealWidth: lastFocusedSurface.value?.initialSize?.width, + idealHeight: lastFocusedSurface.value?.initialSize?.height) } // Ignore safe area to extend up in to the titlebar region if we have the "hidden" titlebar style .ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == "hidden" ? .top : []) diff --git a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift index f9ed364aa..d834f5e63 100644 --- a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift @@ -15,4 +15,20 @@ extension NSWindow { guard let firstWindow = tabGroup?.windows.first else { return true } return firstWindow === self } + + /// Adjusts the window origin if necessary to ensure the window remains visible on screen. + func constrainToScreen() { + guard let screen = screen ?? NSScreen.main else { return } + let visibleFrame = screen.visibleFrame + var windowFrame = frame + + windowFrame.origin.x = max(visibleFrame.minX, + min(windowFrame.origin.x, visibleFrame.maxX - windowFrame.width)) + windowFrame.origin.y = max(visibleFrame.minY, + min(windowFrame.origin.y, visibleFrame.maxY - windowFrame.height)) + + if windowFrame.origin != frame.origin { + setFrameOrigin(windowFrame.origin) + } + } } From 351dd2ea51be4e9416261f3086c2f4967d88ba61 Mon Sep 17 00:00:00 2001 From: rhodes-b <59537185+rhodes-b@users.noreply.github.com> Date: Fri, 28 Nov 2025 19:26:11 -0600 Subject: [PATCH 180/209] allow list themes --plain to accept --color flag --- src/cli/list_themes.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 63184ddfb..eb7cb49a3 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -180,7 +180,13 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 { return 0; } + var theme_config = try Config.default(gpa_alloc); + defer theme_config.deinit(); for (themes.items) |theme| { + try theme_config.loadFile(theme_config._arena.?.allocator(), theme.path); + if (!shouldIncludeTheme(opts.color, theme_config)) { + continue; + } if (opts.path) try stdout.print("{s} ({t}) {s}\n", .{ theme.theme, theme.location, theme.path }) else From 10f19ebdc3260dd5b2d0d27ebb3f7ed2501e3327 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 07:15:31 -0800 Subject: [PATCH 181/209] search: handle soft-wrapped lines in sliding window properly Fixes #9752 --- src/terminal/formatter.zig | 72 ++++++++++++++++++ src/terminal/render.zig | 12 ++- src/terminal/search/sliding_window.zig | 100 ++++++++++++++++++++++--- 3 files changed, 173 insertions(+), 11 deletions(-) diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index 1f4f2468b..74bbfe482 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -825,6 +825,8 @@ pub const PageFormatter = struct { /// byte written to the writer offset by the byte index. It is the /// caller's responsibility to free the map. /// + /// The x/y coordinate will be the coordinates within the page. + /// /// Warning: there is a significant performance hit to track this point_map: ?struct { alloc: Allocator, @@ -1450,6 +1452,76 @@ test "Page plain single line" { ); } +test "Page plain single line soft-wrapped unwrapped" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 3, + .rows = 5, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello!"); + + // Verify we have only a single page + const pages = &t.screens.active.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + // Create the formatter + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ + .emit = .plain, + .unwrap = true, + }); + + // Test our point map. + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + // Verify output + // Note: we don't test the trailing state, which may have bugs + // with unwrap... + _ = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello!", output); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + try testing.expectEqual( + Coordinate{ .x = 0, .y = 0 }, + point_map.items[0], + ); + try testing.expectEqual( + Coordinate{ .x = 1, .y = 0 }, + point_map.items[1], + ); + try testing.expectEqual( + Coordinate{ .x = 2, .y = 0 }, + point_map.items[2], + ); + try testing.expectEqual( + Coordinate{ .x = 0, .y = 1 }, + point_map.items[3], + ); + try testing.expectEqual( + Coordinate{ .x = 1, .y = 1 }, + point_map.items[4], + ); + try testing.expectEqual( + Coordinate{ .x = 2, .y = 1 }, + point_map.items[5], + ); +} + test "Page plain single wide char" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 296360381..83b4a7145 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -703,8 +703,16 @@ pub const RenderState = struct { .{ .tag = tag, .range = .{ - if (i == 0) hl.top_x else 0, - if (i == nodes.len - 1) hl.bot_x else self.cols - 1, + if (i == 0 and + row_pin.y == starts[0]) + hl.top_x + else + 0, + if (i == nodes.len - 1 and + row_pin.y == ends[nodes.len - 1] - 1) + hl.bot_x + else + self.cols - 1, }, }, ); diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index 0d853b3a0..3d64042ce 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -9,6 +9,7 @@ const PageList = terminal.PageList; const Pin = PageList.Pin; const Selection = terminal.Selection; const Screen = terminal.Screen; +const Terminal = terminal.Terminal; const PageFormatter = @import("../formatter.zig").PageFormatter; const FlattenedHighlight = terminal.highlight.Flattened; @@ -462,12 +463,13 @@ pub const SlidingWindow = struct { switch (self.direction) { .forward => {}, .reverse => { + const slice = self.chunk_buf.slice(); + const nodes = slice.items(.node); + const starts = slice.items(.start); + const ends = slice.items(.end); + if (self.chunk_buf.len > 1) { // Reverse all our chunks. This should be pretty obvious why. - const slice = self.chunk_buf.slice(); - const nodes = slice.items(.node); - const starts = slice.items(.start); - const ends = slice.items(.end); std.mem.reverse(*PageList.List.Node, nodes); std.mem.reverse(size.CellCountInt, starts); std.mem.reverse(size.CellCountInt, ends); @@ -484,10 +486,6 @@ pub const SlidingWindow = struct { // We DON'T need to do this for any middle pages because // they always use the full page. // - // We DON'T need to do this for chunks.len == 1 because - // the pages themselves aren't reversed and we don't have - // any prefix/suffix problems. - // // This is a fixup that makes our start/end match the // same logic as the loops above if they were in forward // order. @@ -496,6 +494,13 @@ pub const SlidingWindow = struct { ends[0] = nodes[0].data.size.rows; ends[nodes.len - 1] = starts[nodes.len - 1] + 1; starts[nodes.len - 1] = 0; + } else { + // For a single chunk, the y values are in reverse order + // (start is the screen-end, end is the screen-start). + // Swap them to get proper top-to-bottom order. + const start_y = starts[0]; + starts[0] = ends[0] - 1; + ends[0] = start_y + 1; } // X values also need to be reversed since the top/bottom @@ -539,7 +544,10 @@ pub const SlidingWindow = struct { // Encode the page into the buffer. const formatter: PageFormatter = formatter: { - var formatter: PageFormatter = .init(&meta.node.data, .plain); + var formatter: PageFormatter = .init(&meta.node.data, .{ + .emit = .plain, + .unwrap = true, + }); formatter.point_map = .{ .alloc = self.alloc, .map = &meta.cell_map, @@ -1555,3 +1563,77 @@ test "SlidingWindow single append match on boundary reversed" { } try testing.expect(w.next() == null); } + +test "SlidingWindow single append soft wrapped" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "boo!"); + defer w.deinit(); + + var t: Terminal = try .init(alloc, .{ .cols = 4, .rows = 5 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("A\r\nxxboo!\r\nC"); + + // We want to test single-page cases. + const screen = t.screens.active; + try testing.expect(screen.pages.pages.first == screen.pages.pages.last); + const node: *PageList.List.Node = screen.pages.pages.first.?; + _ = try w.append(node); + + // We should be able to find two matches. + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 2, + .y = 1, + } }, screen.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 2, + } }, screen.pages.pointFromPin(.active, sel.end)); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + +test "SlidingWindow single append reversed soft wrapped" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "boo!"); + defer w.deinit(); + + var t: Terminal = try .init(alloc, .{ .cols = 4, .rows = 5 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("A\r\nxxboo!\r\nC"); + + // We want to test single-page cases. + const screen = t.screens.active; + try testing.expect(screen.pages.pages.first == screen.pages.pages.last); + const node: *PageList.List.Node = screen.pages.pages.first.?; + _ = try w.append(node); + + // We should be able to find two matches. + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 2, + .y = 1, + } }, screen.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 2, + } }, screen.pages.pointFromPin(.active, sel.end)); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} From 643c5e00a076ce298faf7c9b69b39691576bf3b7 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 30 Nov 2025 00:16:02 +0000 Subject: [PATCH 182/209] deps: Update iTerm2 color schemes --- build.zig.zon | 2 +- build.zig.zon.json | 4 ++-- build.zig.zon.nix | 4 ++-- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index fc7d855f4..993904aec 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,7 +116,7 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251110-150531-d5f3d53/ghostty-themes.tgz", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz", .hash = "N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN", .lazy = true, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 6de71dd82..9ca70c410 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -51,8 +51,8 @@ }, "N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251110-150531-d5f3d53/ghostty-themes.tgz", - "hash": "sha256-VZq3L/cAAu7kLA5oqJYNjAZApoblfBtAzfdKVOuJPQI=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz", + "hash": "sha256-5mmXW7d9SkesHyIwUBlWmyGtOWf6wu0S6zkHe93FVLM=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index ae227129b..2563f5411 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -166,8 +166,8 @@ in name = "N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251110-150531-d5f3d53/ghostty-themes.tgz"; - hash = "sha256-VZq3L/cAAu7kLA5oqJYNjAZApoblfBtAzfdKVOuJPQI="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz"; + hash = "sha256-5mmXW7d9SkesHyIwUBlWmyGtOWf6wu0S6zkHe93FVLM="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index c7a5bae21..4362c5d36 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -29,7 +29,7 @@ https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz -https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251110-150531-d5f3d53/ghostty-themes.tgz +https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 5a64f81a8..672fd7a5f 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -61,9 +61,9 @@ }, { "type": "archive", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251110-150531-d5f3d53/ghostty-themes.tgz", + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz", "dest": "vendor/p/N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN", - "sha256": "559ab72ff70002eee42c0e68a8960d8c0640a686e57c1b40cdf74a54eb893d02" + "sha256": "e669975bb77d4a47ac1f22305019569b21ad3967fac2ed12eb39077bddc554b3" }, { "type": "archive", From d7087627d728dda8a3baa45ad104977533b0d0d9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 30 Nov 2025 07:15:23 -0800 Subject: [PATCH 183/209] terminal: renderstate needs to reset highlights on dirty This fixes memory corruption where future matches on a fully dirty row would write highlights out of bounds. It was easy to reproduce in debug by searching for `$` in `ghostty +boo` --- src/terminal/render.zig | 61 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 83b4a7145..b6430ea34 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -385,6 +385,7 @@ pub const RenderState = struct { const row_rows = row_data.items(.raw); const row_cells = row_data.items(.cells); const row_sels = row_data.items(.selection); + const row_highlights = row_data.items(.highlights); const row_dirties = row_data.items(.dirty); // Track the last page that we know was dirty. This lets us @@ -468,6 +469,7 @@ pub const RenderState = struct { _ = arena.reset(.retain_capacity); row_cells[y].clearRetainingCapacity(); row_sels[y] = null; + row_highlights[y] = .empty; } row_dirties[y] = true; @@ -1314,3 +1316,62 @@ test "string" { const expected = "AB\x00\x00\x00\n\x00\x00\x00\x00\x00\n"; try testing.expectEqualStrings(expected, result); } + +test "dirty row resets highlights" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("ABC"); + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Reset dirty state + state.dirty = .false; + { + const row_data = state.row_data.slice(); + const dirty = row_data.items(.dirty); + @memset(dirty, false); + } + + // Manually add a highlight to row 0 + { + const row_data = state.row_data.slice(); + const row_arenas = row_data.items(.arena); + const row_highlights = row_data.items(.highlights); + var arena = row_arenas[0].promote(alloc); + defer row_arenas[0] = arena.state; + try row_highlights[0].append(arena.allocator(), .{ + .tag = 1, + .range = .{ 0, 2 }, + }); + } + + // Verify we have a highlight + { + const row_data = state.row_data.slice(); + const row_highlights = row_data.items(.highlights); + try testing.expectEqual(1, row_highlights[0].items.len); + } + + // Write to row 0 to make it dirty + try s.nextSlice("\x1b[H"); // Move to home + try s.nextSlice("X"); + try state.update(alloc, &t); + + // Verify the highlight was reset on the dirty row + { + const row_data = state.row_data.slice(); + const row_highlights = row_data.items(.highlights); + try testing.expectEqual(0, row_highlights[0].items.len); + } +} From a58e33c06bfdefec663a879b5f056f11a3d41a24 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Sun, 30 Nov 2025 08:25:04 -0600 Subject: [PATCH 184/209] PageList: preserve size.cols in adjustCapacity after column shrink When columns shrink during resize-without-reflow, page.size.cols is updated but page.capacity.cols retains the old larger value. When adjustCapacity later runs (e.g., to expand style/grapheme storage), it was creating a new page using page.capacity which has the stale column count, causing size.cols to revert to the old value. This caused a crash in render.zig where an assertion checks that page.size.cols matches PageList.cols. Fix by explicitly copying page.size.cols to the new page after creation, matching how size.rows is already handled. Amp-Thread-ID: https://ampcode.com/threads/T-976bc49a-7bfd-40bd-bbbb-38f66fc925ff Co-authored-by: Amp --- src/terminal/PageList.zig | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index e7cb56da7..29f414e03 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2608,7 +2608,9 @@ pub fn adjustCapacity( errdefer self.destroyNode(new_node); const new_page: *Page = &new_node.data; assert(new_page.capacity.rows >= page.capacity.rows); + assert(new_page.capacity.cols >= page.capacity.cols); new_page.size.rows = page.size.rows; + new_page.size.cols = page.size.cols; try new_page.cloneFrom(page, 0, page.size.rows); // Fix up all our tracked pins to point to the new page. @@ -6257,6 +6259,39 @@ test "PageList adjustCapacity to increase hyperlinks" { } } +test "PageList adjustCapacity after col shrink" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 2, 0); + defer s.deinit(); + + // Shrink columns - this updates size.cols but not capacity.cols + try s.resize(.{ .cols = 5, .reflow = false }); + try testing.expectEqual(5, s.cols); + + { + const page = &s.pages.first.?.data; + // capacity.cols is still 10, but size.cols should be 5 + try testing.expectEqual(5, page.size.cols); + try testing.expect(page.capacity.cols >= 10); + } + + // Now adjust capacity (e.g., to increase styles) + // This should preserve the current size.cols, not revert to capacity.cols + _ = try s.adjustCapacity( + s.pages.first.?, + .{ .styles = std_capacity.styles * 2 }, + ); + + { + const page = &s.pages.first.?.data; + // After adjustCapacity, size.cols should still be 5, not 10 + try testing.expectEqual(5, page.size.cols); + try testing.expectEqual(5, s.cols); + } +} + test "PageList pageIterator single page" { const testing = std.testing; const alloc = testing.allocator; From 832883b600dcbd9435a48174e638a19bdb205626 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 14:45:20 -0800 Subject: [PATCH 185/209] apprt/gtk: move surface event controllers, block events from revealers --- src/apprt/gtk/ui/1.2/surface.blp | 61 +++++++++++++++++--------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 0596bf15d..8ff4a2e78 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -41,6 +41,34 @@ Overlay terminal_page { halign: start; has-arrow: false; } + + EventControllerFocus { + enter => $focus_enter(); + leave => $focus_leave(); + } + + EventControllerKey { + key-pressed => $key_pressed(); + key-released => $key_released(); + } + + EventControllerScroll { + scroll => $scroll(); + scroll-begin => $scroll_begin(); + scroll-end => $scroll_end(); + flags: both_axes; + } + + EventControllerMotion { + motion => $mouse_motion(); + leave => $mouse_leave(); + } + + GestureClick { + pressed => $mouse_down(); + released => $mouse_up(); + button: 0; + } }; [overlay] @@ -64,6 +92,10 @@ Overlay terminal_page { reveal-child: bind $should_border_be_shown(template.config, template.bell-ringing) as ; transition-type: crossfade; transition-duration: 500; + // Revealers take up the full size, we need this to not capture events. + can-focus: false; + can-target: false; + focusable: false; Box bell_overlay { styles [ @@ -129,35 +161,6 @@ Overlay terminal_page { } } - // Event controllers for interactivity - EventControllerFocus { - enter => $focus_enter(); - leave => $focus_leave(); - } - - EventControllerKey { - key-pressed => $key_pressed(); - key-released => $key_released(); - } - - EventControllerMotion { - motion => $mouse_motion(); - leave => $mouse_leave(); - } - - EventControllerScroll { - scroll => $scroll(); - scroll-begin => $scroll_begin(); - scroll-end => $scroll_end(); - flags: both_axes; - } - - GestureClick { - pressed => $mouse_down(); - released => $mouse_up(); - button: 0; - } - DropTarget drop_target { drop => $drop(); actions: copy; From 548d1f0300ca65b0f99c22d385b26cc6d667485a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 14:11:42 -0800 Subject: [PATCH 186/209] apprt/gtk: search overlay UI --- src/apprt/gtk/build/gresource.zig | 1 + src/apprt/gtk/class/search_overlay.zig | 141 ++++++++++++++++++++++++ src/apprt/gtk/class/surface.zig | 2 + src/apprt/gtk/css/style.css | 12 ++ src/apprt/gtk/ui/1.2/search-overlay.blp | 72 ++++++++++++ src/apprt/gtk/ui/1.2/surface.blp | 12 ++ 6 files changed, 240 insertions(+) create mode 100644 src/apprt/gtk/class/search_overlay.zig create mode 100644 src/apprt/gtk/ui/1.2/search-overlay.blp diff --git a/src/apprt/gtk/build/gresource.zig b/src/apprt/gtk/build/gresource.zig index cc701d7c2..c77579aab 100644 --- a/src/apprt/gtk/build/gresource.zig +++ b/src/apprt/gtk/build/gresource.zig @@ -43,6 +43,7 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 5, .name = "inspector-widget" }, .{ .major = 1, .minor = 5, .name = "inspector-window" }, .{ .major = 1, .minor = 2, .name = "resize-overlay" }, + .{ .major = 1, .minor = 2, .name = "search-overlay" }, .{ .major = 1, .minor = 5, .name = "split-tree" }, .{ .major = 1, .minor = 5, .name = "split-tree-split" }, .{ .major = 1, .minor = 2, .name = "surface" }, diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig new file mode 100644 index 000000000..1e49750fa --- /dev/null +++ b/src/apprt/gtk/class/search_overlay.zig @@ -0,0 +1,141 @@ +const std = @import("std"); +const adw = @import("adw"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const gresource = @import("../build/gresource.zig"); +const Common = @import("../class.zig").Common; + +const log = std.log.scoped(.gtk_ghostty_search_overlay); + +/// The overlay that shows the current size while a surface is resizing. +/// This can be used generically to show pretty much anything with a +/// disappearing overlay, but we have no other use at this point so it +/// is named specifically for what it does. +/// +/// General usage: +/// +/// 1. Add it to an overlay +/// 2. Set the label with `setLabel` +/// 3. Schedule to show it with `schedule` +/// +/// Set any properties to change the behavior. +pub const SearchOverlay = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttySearchOverlay", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const duration = struct { + pub const name = "duration"; + const impl = gobject.ext.defineProperty( + name, + Self, + c_uint, + .{ + .default = 750, + .minimum = 250, + .maximum = std.math.maxInt(c_uint), + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "duration", + ), + }, + ); + }; + }; + + const Private = struct { + /// The time that the overlay appears. + duration: c_uint, + + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + const priv = self.private(); + _ = priv; + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + _ = priv; + + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn finalize(self: *Self) callconv(.c) void { + const priv = self.private(); + _ = priv; + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 2, + .name = "search-overlay", + }), + ); + + // Bindings + // class.bindTemplateChildPrivate("label", .{}); + + // Properties + // gobject.ext.registerProperties(class, &.{ + // properties.duration.impl, + // properties.label.impl, + // properties.@"first-delay".impl, + // properties.@"overlay-halign".impl, + // properties.@"overlay-valign".impl, + // }); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + }; +}; diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 9ba7ce0ab..587392464 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -25,6 +25,7 @@ const Common = @import("../class.zig").Common; const Application = @import("application.zig").Application; const Config = @import("config.zig").Config; const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay; +const SearchOverlay = @import("search_overlay.zig").SearchOverlay; const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited; const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog; const TitleDialog = @import("surface_title_dialog.zig").SurfaceTitleDialog; @@ -3184,6 +3185,7 @@ pub const Surface = extern struct { fn init(class: *Class) callconv(.c) void { gobject.ext.ensureType(ResizeOverlay); + gobject.ext.ensureType(SearchOverlay); gobject.ext.ensureType(ChildExited); gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), diff --git a/src/apprt/gtk/css/style.css b/src/apprt/gtk/css/style.css index 5620c9ca4..938d23ad8 100644 --- a/src/apprt/gtk/css/style.css +++ b/src/apprt/gtk/css/style.css @@ -34,6 +34,18 @@ label.url-overlay.right { border-radius: 6px 0px 0px 0px; } +/* + * GhosttySurface search overlay + */ +.search-overlay { + padding: 6px 8px; + margin: 8px; + border-radius: 8px; + outline-style: solid; + outline-color: #555555; + outline-width: 1px; +} + /* * GhosttySurface resize overlay */ diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp new file mode 100644 index 000000000..030780260 --- /dev/null +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -0,0 +1,72 @@ +using Gtk 4.0; +using Gdk 4.0; +using Adw 1; + +template $GhosttySearchOverlay: Adw.Bin { + halign: end; + valign: start; + + Adw.Bin { + Box container { + styles [ + "background", + "search-overlay", + ] + + orientation: horizontal; + spacing: 6; + + SearchEntry search_entry { + placeholder-text: _("Find…"); + width-chars: 20; + hexpand: true; + } + + Label match_label { + styles [ + "dim-label", + ] + + label: "0/0"; + width-chars: 6; + xalign: 1.0; + } + + Box button_box { + orientation: horizontal; + spacing: 1; + + styles [ + "linked", + ] + + Button prev_button { + icon-name: "go-up-symbolic"; + tooltip-text: _("Previous Match"); + + cursor: Gdk.Cursor { + name: "pointer"; + }; + } + + Button next_button { + icon-name: "go-down-symbolic"; + tooltip-text: _("Next Match"); + + cursor: Gdk.Cursor { + name: "pointer"; + }; + } + } + + Button close_button { + icon-name: "window-close-symbolic"; + tooltip-text: _("Close"); + + cursor: Gdk.Cursor { + name: "pointer"; + }; + } + } + } +} diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 8ff4a2e78..3b382259d 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -147,12 +147,24 @@ Overlay terminal_page { label: bind template.mouse-hover-url; } + [overlay] + $GhosttySearchOverlay search_overlay { + halign: end; + valign: start; + } + [overlay] // Apply unfocused-split-fill and unfocused-split-opacity to current surface // this is only applied when a tab has more than one surface Revealer { reveal-child: bind $should_unfocused_split_be_shown(template.focused, template.is-split) as ; transition-duration: 0; + // This is all necessary so that the Revealer itself doesn't override + // any input events from the other overlays. Namely, if you don't have + // these then the search overlay won't get mouse events. + can-focus: false; + can-target: false; + focusable: false; DrawingArea { styles [ From 027e5d631afce2b8ea2d5eb991b6539dd45e0334 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 14:59:50 -0800 Subject: [PATCH 187/209] config: default search keybindings for Linux --- src/config/Config.zig | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index bac7d3443..82e81a01f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6098,6 +6098,20 @@ pub const Keybinds = struct { .{ .jump_to_prompt = 1 }, ); + // Search + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'f' }, .mods = .{ .ctrl = true, .shift = true } }, + .start_search, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .escape } }, + .end_search, + .{ .performable = true }, + ); + // Inspector, matching Chromium try self.set.put( alloc, From 778b49c9a164df4e6118c857fdcfbea8360aff0c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 14:53:30 -0800 Subject: [PATCH 188/209] apprt/gtk: hook up start_search/end_search to set active state --- src/apprt/gtk/class/application.zig | 19 +++++++++-- src/apprt/gtk/class/search_overlay.zig | 42 +++++++++++-------------- src/apprt/gtk/class/surface.zig | 18 +++++++++++ src/apprt/gtk/ui/1.2/search-overlay.blp | 1 + 4 files changed, 55 insertions(+), 25 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index cc070240c..0efa7a3e0 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -727,6 +727,9 @@ pub const Application = extern struct { .show_on_screen_keyboard => return Action.showOnScreenKeyboard(target), .command_finished => return Action.commandFinished(target, value), + .start_search => Action.startSearch(target), + .end_search => Action.endSearch(target), + // Unimplemented .secure_input, .close_all_windows, @@ -741,8 +744,6 @@ pub const Application = extern struct { .check_for_updates, .undo, .redo, - .start_search, - .end_search, .search_total, .search_selected, => { @@ -2341,6 +2342,20 @@ const Action = struct { } } + pub fn startSearch(target: apprt.Target) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchActive(true), + } + } + + pub fn endSearch(target: apprt.Target) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchActive(false), + } + } + pub fn setTitle( target: apprt.Target, value: apprt.action.SetTitle, diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index 1e49750fa..67c6ba38c 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -34,39 +34,39 @@ pub const SearchOverlay = extern struct { }); pub const properties = struct { - pub const duration = struct { - pub const name = "duration"; + pub const active = struct { + pub const name = "active"; const impl = gobject.ext.defineProperty( name, Self, - c_uint, + bool, .{ - .default = 750, - .minimum = 250, - .maximum = std.math.maxInt(c_uint), - .accessor = gobject.ext.privateFieldAccessor( - Self, - Private, - &Private.offset, - "duration", - ), + .default = false, + .accessor = C.privateShallowFieldAccessor("active"), }, ); }; }; const Private = struct { - /// The time that the overlay appears. - duration: c_uint, + /// The search entry widget. + search_entry: *gtk.SearchEntry, + + /// True when a search is active, meaning we should show the overlay. + active: bool = false, pub var offset: c_int = 0; }; fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + /// Grab focus on the search entry and select all text. + pub fn grabFocus(self: *Self) void { const priv = self.private(); - _ = priv; + _ = priv.search_entry.as(gtk.Widget).grabFocus(); + priv.search_entry.as(gtk.Editable).selectRegion(0, -1); } //--------------------------------------------------------------- @@ -119,16 +119,12 @@ pub const SearchOverlay = extern struct { ); // Bindings - // class.bindTemplateChildPrivate("label", .{}); + class.bindTemplateChildPrivate("search_entry", .{}); // Properties - // gobject.ext.registerProperties(class, &.{ - // properties.duration.impl, - // properties.label.impl, - // properties.@"first-delay".impl, - // properties.@"overlay-halign".impl, - // properties.@"overlay-valign".impl, - // }); + gobject.ext.registerProperties(class, &.{ + properties.active.impl, + }); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 587392464..a91ae9d45 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -550,6 +550,9 @@ pub const Surface = extern struct { /// The resize overlay resize_overlay: *ResizeOverlay, + /// The search overlay + search_overlay: *SearchOverlay, + /// The apprt Surface. rt_surface: ApprtSurface = undefined, @@ -1952,6 +1955,20 @@ pub const Surface = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"error".impl.param_spec); } + pub fn setSearchActive(self: *Self, active: bool) void { + const priv = self.private(); + var value = gobject.ext.Value.newFrom(active); + defer value.unset(); + gobject.Object.setProperty( + priv.search_overlay.as(gobject.Object), + SearchOverlay.properties.active.name, + &value, + ); + if (active) { + priv.search_overlay.grabFocus(); + } + } + fn propConfig( self: *Self, _: *gobject.ParamSpec, @@ -3205,6 +3222,7 @@ pub const Surface = extern struct { class.bindTemplateChildPrivate("error_page", .{}); class.bindTemplateChildPrivate("progress_bar_overlay", .{}); class.bindTemplateChildPrivate("resize_overlay", .{}); + class.bindTemplateChildPrivate("search_overlay", .{}); class.bindTemplateChildPrivate("terminal_page", .{}); class.bindTemplateChildPrivate("drop_target", .{}); class.bindTemplateChildPrivate("im_context", .{}); diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index 030780260..79e3ef58f 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -3,6 +3,7 @@ using Gdk 4.0; using Adw 1; template $GhosttySearchOverlay: Adw.Bin { + visible: bind template.active; halign: end; valign: start; From 0d32e7d814264c8f84c397235fae206774eeac90 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 15:10:38 -0800 Subject: [PATCH 189/209] apprt/gtk: escape to stop search and hide overlay --- src/apprt/gtk/class/search_overlay.zig | 28 +++++++++++++++++++++++++ src/apprt/gtk/class/surface.zig | 9 ++++++++ src/apprt/gtk/ui/1.2/search-overlay.blp | 1 + src/apprt/gtk/ui/1.2/surface.blp | 1 + 4 files changed, 39 insertions(+) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index 67c6ba38c..75aedc154 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -48,6 +48,20 @@ pub const SearchOverlay = extern struct { }; }; + pub const signals = struct { + /// Emitted when the search is stopped (e.g., Escape pressed). + pub const @"stop-search" = struct { + pub const name = "stop-search"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + }; + const Private = struct { /// The search entry widget. search_entry: *gtk.SearchEntry, @@ -69,6 +83,13 @@ pub const SearchOverlay = extern struct { priv.search_entry.as(gtk.Editable).selectRegion(0, -1); } + //--------------------------------------------------------------- + // Template callbacks + + fn stopSearch(_: *gtk.SearchEntry, self: *Self) callconv(.c) void { + signals.@"stop-search".impl.emit(self, null, .{}, null); + } + //--------------------------------------------------------------- // Virtual methods @@ -121,11 +142,17 @@ pub const SearchOverlay = extern struct { // Bindings class.bindTemplateChildPrivate("search_entry", .{}); + // Template Callbacks + class.bindTemplateCallback("stop_search", &stopSearch); + // Properties gobject.ext.registerProperties(class, &.{ properties.active.impl, }); + // Signals + signals.@"stop-search".impl.register(.{}); + // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); gobject.Object.virtual_methods.finalize.implement(class, &finalize); @@ -133,5 +160,6 @@ pub const SearchOverlay = extern struct { pub const as = C.Class.as; pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; }; }; diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index a91ae9d45..405beea3e 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3188,6 +3188,14 @@ pub const Surface = extern struct { self.setTitleOverride(if (title.len == 0) null else title); } + fn searchStop(_: *SearchOverlay, self: *Self) callconv(.c) void { + // Note: at the time of writing this, this behavior doesn't match + // macOS. But I think it makes more sense on Linux/GTK to do this. + // We may follow suit on macOS in the future. + self.setSearchActive(false); + _ = self.private().gl_area.as(gtk.Widget).grabFocus(); + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -3260,6 +3268,7 @@ pub const Surface = extern struct { class.bindTemplateCallback("notify_vadjustment", &propVAdjustment); class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown); class.bindTemplateCallback("should_unfocused_split_be_shown", &closureShouldUnfocusedSplitBeShown); + class.bindTemplateCallback("search_stop", &searchStop); // Properties gobject.ext.registerProperties(class, &.{ diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index 79e3ef58f..b9d282df5 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -21,6 +21,7 @@ template $GhosttySearchOverlay: Adw.Bin { placeholder-text: _("Find…"); width-chars: 20; hexpand: true; + stop-search => $stop_search(); } Label match_label { diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 3b382259d..9803b47e0 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -151,6 +151,7 @@ Overlay terminal_page { $GhosttySearchOverlay search_overlay { halign: end; valign: start; + stop-search => $search_stop(); } [overlay] From fc9b578ef42aae4c55f76aefe003dd76835e4516 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 15:16:29 -0800 Subject: [PATCH 190/209] apprt/gtk: hook up search-changed to start a search --- src/apprt/gtk/class/search_overlay.zig | 19 +++++++++++++++++++ src/apprt/gtk/class/surface.zig | 8 ++++++++ src/apprt/gtk/ui/1.2/search-overlay.blp | 1 + src/apprt/gtk/ui/1.2/surface.blp | 1 + 4 files changed, 29 insertions(+) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index 75aedc154..5cc64be62 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -60,6 +60,18 @@ pub const SearchOverlay = extern struct { void, ); }; + + /// Emitted when the search text changes (debounced). + pub const @"search-changed" = struct { + pub const name = "search-changed"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{?[*:0]const u8}, + void, + ); + }; }; const Private = struct { @@ -90,6 +102,11 @@ pub const SearchOverlay = extern struct { signals.@"stop-search".impl.emit(self, null, .{}, null); } + fn searchChanged(entry: *gtk.SearchEntry, self: *Self) callconv(.c) void { + const text = entry.as(gtk.Editable).getText(); + signals.@"search-changed".impl.emit(self, null, .{text}, null); + } + //--------------------------------------------------------------- // Virtual methods @@ -144,6 +161,7 @@ pub const SearchOverlay = extern struct { // Template Callbacks class.bindTemplateCallback("stop_search", &stopSearch); + class.bindTemplateCallback("search_changed", &searchChanged); // Properties gobject.ext.registerProperties(class, &.{ @@ -152,6 +170,7 @@ pub const SearchOverlay = extern struct { // Signals signals.@"stop-search".impl.register(.{}); + signals.@"search-changed".impl.register(.{}); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 405beea3e..66663dc53 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3196,6 +3196,13 @@ pub const Surface = extern struct { _ = self.private().gl_area.as(gtk.Widget).grabFocus(); } + fn searchChanged(_: *SearchOverlay, needle: ?[*:0]const u8, self: *Self) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.{ .search = std.mem.sliceTo(needle orelse "", 0) }) catch |err| { + log.warn("unable to perform search action err={}", .{err}); + }; + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -3269,6 +3276,7 @@ pub const Surface = extern struct { class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown); class.bindTemplateCallback("should_unfocused_split_be_shown", &closureShouldUnfocusedSplitBeShown); class.bindTemplateCallback("search_stop", &searchStop); + class.bindTemplateCallback("search_changed", &searchChanged); // Properties gobject.ext.registerProperties(class, &.{ diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index b9d282df5..18d7f4e5c 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -22,6 +22,7 @@ template $GhosttySearchOverlay: Adw.Bin { width-chars: 20; hexpand: true; stop-search => $stop_search(); + search-changed => $search_changed(); } Label match_label { diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 9803b47e0..7f1c1b01f 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -152,6 +152,7 @@ Overlay terminal_page { halign: end; valign: start; stop-search => $search_stop(); + search-changed => $search_changed(); } [overlay] From 0ea85fc483a4fe780877ec5e3e774a8edd037466 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 15:22:29 -0800 Subject: [PATCH 191/209] apprt/gtk: hook up search_total/search_selected apprt actions --- src/apprt/gtk/class/application.zig | 18 ++++++- src/apprt/gtk/class/search_overlay.zig | 64 +++++++++++++++++++++++++ src/apprt/gtk/class/surface.zig | 8 ++++ src/apprt/gtk/ui/1.2/search-overlay.blp | 4 +- 4 files changed, 90 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 0efa7a3e0..69576bf00 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -729,6 +729,8 @@ pub const Application = extern struct { .start_search => Action.startSearch(target), .end_search => Action.endSearch(target), + .search_total => Action.searchTotal(target, value), + .search_selected => Action.searchSelected(target, value), // Unimplemented .secure_input, @@ -744,8 +746,6 @@ pub const Application = extern struct { .check_for_updates, .undo, .redo, - .search_total, - .search_selected, => { log.warn("unimplemented action={}", .{action}); return false; @@ -2356,6 +2356,20 @@ const Action = struct { } } + pub fn searchTotal(target: apprt.Target, value: apprt.action.SearchTotal) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchTotal(value.total), + } + } + + pub fn searchSelected(target: apprt.Target, value: apprt.action.SearchSelected) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchSelected(value.selected), + } + } + pub fn setTitle( target: apprt.Target, value: apprt.action.SetTitle, diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index 5cc64be62..eee7b7bc1 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -46,6 +46,36 @@ pub const SearchOverlay = extern struct { }, ); }; + + pub const @"search-total" = struct { + pub const name = "search-total"; + const impl = gobject.ext.defineProperty( + name, + Self, + i64, + .{ + .default = -1, + .minimum = -1, + .maximum = std.math.maxInt(i64), + .accessor = C.privateShallowFieldAccessor("search_total"), + }, + ); + }; + + pub const @"search-selected" = struct { + pub const name = "search-selected"; + const impl = gobject.ext.defineProperty( + name, + Self, + i64, + .{ + .default = -1, + .minimum = -1, + .maximum = std.math.maxInt(i64), + .accessor = C.privateShallowFieldAccessor("search_selected"), + }, + ); + }; }; pub const signals = struct { @@ -81,6 +111,12 @@ pub const SearchOverlay = extern struct { /// True when a search is active, meaning we should show the overlay. active: bool = false, + /// Total number of search matches (-1 means unknown/none). + search_total: i64 = -1, + + /// Currently selected match index (-1 means none selected). + search_selected: i64 = -1, + pub var offset: c_int = 0; }; @@ -95,6 +131,31 @@ pub const SearchOverlay = extern struct { priv.search_entry.as(gtk.Editable).selectRegion(0, -1); } + /// Set the total number of search matches. + pub fn setSearchTotal(self: *Self, total: ?usize) void { + const value: i64 = if (total) |t| @intCast(t) else -1; + var gvalue = gobject.ext.Value.newFrom(value); + defer gvalue.unset(); + self.as(gobject.Object).setProperty(properties.@"search-total".name, &gvalue); + } + + /// Set the currently selected match index. + pub fn setSearchSelected(self: *Self, selected: ?usize) void { + const value: i64 = if (selected) |s| @intCast(s) else -1; + var gvalue = gobject.ext.Value.newFrom(value); + defer gvalue.unset(); + self.as(gobject.Object).setProperty(properties.@"search-selected".name, &gvalue); + } + + fn closureMatchLabel(_: *Self, selected: i64, total: i64) callconv(.c) ?[*:0]const u8 { + var buf: [32]u8 = undefined; + const label = std.fmt.bufPrintZ(&buf, "{}/{}", .{ + if (selected >= 0) selected else 0, + if (total >= 0) total else 0, + }) catch return null; + return glib.ext.dupeZ(u8, label); + } + //--------------------------------------------------------------- // Template callbacks @@ -162,10 +223,13 @@ pub const SearchOverlay = extern struct { // Template Callbacks class.bindTemplateCallback("stop_search", &stopSearch); class.bindTemplateCallback("search_changed", &searchChanged); + class.bindTemplateCallback("match_label_closure", &closureMatchLabel); // Properties gobject.ext.registerProperties(class, &.{ properties.active.impl, + properties.@"search-total".impl, + properties.@"search-selected".impl, }); // Signals diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 66663dc53..5951b49f6 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1969,6 +1969,14 @@ pub const Surface = extern struct { } } + pub fn setSearchTotal(self: *Self, total: ?usize) void { + self.private().search_overlay.setSearchTotal(total); + } + + pub fn setSearchSelected(self: *Self, selected: ?usize) void { + self.private().search_overlay.setSearchSelected(selected); + } + fn propConfig( self: *Self, _: *gobject.ParamSpec, diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index 18d7f4e5c..43ede3178 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -25,12 +25,12 @@ template $GhosttySearchOverlay: Adw.Bin { search-changed => $search_changed(); } - Label match_label { + Label { styles [ "dim-label", ] - label: "0/0"; + label: bind $match_label_closure(template.search-selected, template.search-total) as ; width-chars: 6; xalign: 1.0; } From 76496d40fdcc0c6aaadf98e3153effdd10dc2cdf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 15:27:52 -0800 Subject: [PATCH 192/209] apprt/gtk: hook up next/prev match --- src/apprt/gtk/class/search_overlay.zig | 46 +++++++++++++++++++++++++ src/apprt/gtk/class/surface.zig | 16 +++++++++ src/apprt/gtk/ui/1.2/search-overlay.blp | 4 +++ src/apprt/gtk/ui/1.2/surface.blp | 2 ++ 4 files changed, 68 insertions(+) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index eee7b7bc1..75a28de80 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -102,6 +102,30 @@ pub const SearchOverlay = extern struct { void, ); }; + + /// Emitted when navigating to the next match. + pub const @"next-match" = struct { + pub const name = "next-match"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + + /// Emitted when navigating to the previous match. + pub const @"previous-match" = struct { + pub const name = "previous-match"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; }; const Private = struct { @@ -168,6 +192,22 @@ pub const SearchOverlay = extern struct { signals.@"search-changed".impl.emit(self, null, .{text}, null); } + fn nextMatch(_: *gtk.Button, self: *Self) callconv(.c) void { + signals.@"next-match".impl.emit(self, null, .{}, null); + } + + fn previousMatch(_: *gtk.Button, self: *Self) callconv(.c) void { + signals.@"previous-match".impl.emit(self, null, .{}, null); + } + + fn nextMatchEntry(_: *gtk.SearchEntry, self: *Self) callconv(.c) void { + signals.@"next-match".impl.emit(self, null, .{}, null); + } + + fn previousMatchEntry(_: *gtk.SearchEntry, self: *Self) callconv(.c) void { + signals.@"previous-match".impl.emit(self, null, .{}, null); + } + //--------------------------------------------------------------- // Virtual methods @@ -224,6 +264,10 @@ pub const SearchOverlay = extern struct { class.bindTemplateCallback("stop_search", &stopSearch); class.bindTemplateCallback("search_changed", &searchChanged); class.bindTemplateCallback("match_label_closure", &closureMatchLabel); + class.bindTemplateCallback("next_match", &nextMatch); + class.bindTemplateCallback("previous_match", &previousMatch); + class.bindTemplateCallback("next_match_entry", &nextMatchEntry); + class.bindTemplateCallback("previous_match_entry", &previousMatchEntry); // Properties gobject.ext.registerProperties(class, &.{ @@ -235,6 +279,8 @@ pub const SearchOverlay = extern struct { // Signals signals.@"stop-search".impl.register(.{}); signals.@"search-changed".impl.register(.{}); + signals.@"next-match".impl.register(.{}); + signals.@"previous-match".impl.register(.{}); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 5951b49f6..9a77c4c53 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3211,6 +3211,20 @@ pub const Surface = extern struct { }; } + fn searchNextMatch(_: *SearchOverlay, self: *Self) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.{ .navigate_search = .next }) catch |err| { + log.warn("unable to perform navigate_search action err={}", .{err}); + }; + } + + fn searchPreviousMatch(_: *SearchOverlay, self: *Self) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.{ .navigate_search = .previous }) catch |err| { + log.warn("unable to perform navigate_search action err={}", .{err}); + }; + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -3285,6 +3299,8 @@ pub const Surface = extern struct { class.bindTemplateCallback("should_unfocused_split_be_shown", &closureShouldUnfocusedSplitBeShown); class.bindTemplateCallback("search_stop", &searchStop); class.bindTemplateCallback("search_changed", &searchChanged); + class.bindTemplateCallback("search_next_match", &searchNextMatch); + class.bindTemplateCallback("search_previous_match", &searchPreviousMatch); // Properties gobject.ext.registerProperties(class, &.{ diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index 43ede3178..62401959e 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -23,6 +23,8 @@ template $GhosttySearchOverlay: Adw.Bin { hexpand: true; stop-search => $stop_search(); search-changed => $search_changed(); + next-match => $next_match_entry(); + previous-match => $previous_match_entry(); } Label { @@ -46,6 +48,7 @@ template $GhosttySearchOverlay: Adw.Bin { Button prev_button { icon-name: "go-up-symbolic"; tooltip-text: _("Previous Match"); + clicked => $next_match(); cursor: Gdk.Cursor { name: "pointer"; @@ -55,6 +58,7 @@ template $GhosttySearchOverlay: Adw.Bin { Button next_button { icon-name: "go-down-symbolic"; tooltip-text: _("Next Match"); + clicked => $previous_match(); cursor: Gdk.Cursor { name: "pointer"; diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 7f1c1b01f..0abc6c356 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -153,6 +153,8 @@ Overlay terminal_page { valign: start; stop-search => $search_stop(); search-changed => $search_changed(); + next-match => $search_next_match(); + previous-match => $search_previous_match(); } [overlay] From eebce6a78cc7d5a0073239bcc31eaadd35a16830 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 15:31:28 -0800 Subject: [PATCH 193/209] apprt/gtk: hook up close search button --- src/apprt/gtk/class/search_overlay.zig | 5 +++++ src/apprt/gtk/class/surface.zig | 8 ++++---- src/apprt/gtk/ui/1.2/search-overlay.blp | 1 + 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index 75a28de80..46c489f75 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -187,6 +187,10 @@ pub const SearchOverlay = extern struct { signals.@"stop-search".impl.emit(self, null, .{}, null); } + fn stopSearchButton(_: *gtk.Button, self: *Self) callconv(.c) void { + signals.@"stop-search".impl.emit(self, null, .{}, null); + } + fn searchChanged(entry: *gtk.SearchEntry, self: *Self) callconv(.c) void { const text = entry.as(gtk.Editable).getText(); signals.@"search-changed".impl.emit(self, null, .{text}, null); @@ -262,6 +266,7 @@ pub const SearchOverlay = extern struct { // Template Callbacks class.bindTemplateCallback("stop_search", &stopSearch); + class.bindTemplateCallback("stop_search_button", &stopSearchButton); class.bindTemplateCallback("search_changed", &searchChanged); class.bindTemplateCallback("match_label_closure", &closureMatchLabel); class.bindTemplateCallback("next_match", &nextMatch); diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 9a77c4c53..2af53e1ef 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3197,10 +3197,10 @@ pub const Surface = extern struct { } fn searchStop(_: *SearchOverlay, self: *Self) callconv(.c) void { - // Note: at the time of writing this, this behavior doesn't match - // macOS. But I think it makes more sense on Linux/GTK to do this. - // We may follow suit on macOS in the future. - self.setSearchActive(false); + const surface = self.core() orelse return; + _ = surface.performBindingAction(.end_search) catch |err| { + log.warn("unable to perform end_search action err={}", .{err}); + }; _ = self.private().gl_area.as(gtk.Widget).grabFocus(); } diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index 62401959e..0d2dd659b 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -69,6 +69,7 @@ template $GhosttySearchOverlay: Adw.Bin { Button close_button { icon-name: "window-close-symbolic"; tooltip-text: _("Close"); + clicked => $stop_search_button(); cursor: Gdk.Cursor { name: "pointer"; From 56a76cc1746933cdefb2a7d36958c9ecb2a406d2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 15:33:05 -0800 Subject: [PATCH 194/209] apprt/gtk: fix selected search label off by one --- src/apprt/gtk/class/search_overlay.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index 46c489f75..396946062 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -172,9 +172,10 @@ pub const SearchOverlay = extern struct { } fn closureMatchLabel(_: *Self, selected: i64, total: i64) callconv(.c) ?[*:0]const u8 { + if (total <= 0) return glib.ext.dupeZ(u8, "0/0"); var buf: [32]u8 = undefined; const label = std.fmt.bufPrintZ(&buf, "{}/{}", .{ - if (selected >= 0) selected else 0, + if (selected >= 0) selected + 1 else 0, if (total >= 0) total else 0, }) catch return null; return glib.ext.dupeZ(u8, label); From 72b3c14833d11053681605948c35eae5a8f744ad Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 20:16:48 -0800 Subject: [PATCH 195/209] clean up some stuff --- src/apprt/gtk/class/search_overlay.zig | 30 ++++++++----------------- src/apprt/gtk/ui/1.2/search-overlay.blp | 6 ++--- src/apprt/gtk/ui/1.2/surface.blp | 2 -- 3 files changed, 12 insertions(+), 26 deletions(-) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index 396946062..e469e1903 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -184,32 +184,23 @@ pub const SearchOverlay = extern struct { //--------------------------------------------------------------- // Template callbacks - fn stopSearch(_: *gtk.SearchEntry, self: *Self) callconv(.c) void { - signals.@"stop-search".impl.emit(self, null, .{}, null); - } - - fn stopSearchButton(_: *gtk.Button, self: *Self) callconv(.c) void { - signals.@"stop-search".impl.emit(self, null, .{}, null); - } - fn searchChanged(entry: *gtk.SearchEntry, self: *Self) callconv(.c) void { const text = entry.as(gtk.Editable).getText(); signals.@"search-changed".impl.emit(self, null, .{text}, null); } - fn nextMatch(_: *gtk.Button, self: *Self) callconv(.c) void { + // NOTE: The callbacks below use anyopaque for the first parameter + // because they're shared with multiple widgets in the template. + + fn stopSearch(_: *anyopaque, self: *Self) callconv(.c) void { + signals.@"stop-search".impl.emit(self, null, .{}, null); + } + + fn nextMatch(_: *anyopaque, self: *Self) callconv(.c) void { signals.@"next-match".impl.emit(self, null, .{}, null); } - fn previousMatch(_: *gtk.Button, self: *Self) callconv(.c) void { - signals.@"previous-match".impl.emit(self, null, .{}, null); - } - - fn nextMatchEntry(_: *gtk.SearchEntry, self: *Self) callconv(.c) void { - signals.@"next-match".impl.emit(self, null, .{}, null); - } - - fn previousMatchEntry(_: *gtk.SearchEntry, self: *Self) callconv(.c) void { + fn previousMatch(_: *anyopaque, self: *Self) callconv(.c) void { signals.@"previous-match".impl.emit(self, null, .{}, null); } @@ -267,13 +258,10 @@ pub const SearchOverlay = extern struct { // Template Callbacks class.bindTemplateCallback("stop_search", &stopSearch); - class.bindTemplateCallback("stop_search_button", &stopSearchButton); class.bindTemplateCallback("search_changed", &searchChanged); class.bindTemplateCallback("match_label_closure", &closureMatchLabel); class.bindTemplateCallback("next_match", &nextMatch); class.bindTemplateCallback("previous_match", &previousMatch); - class.bindTemplateCallback("next_match_entry", &nextMatchEntry); - class.bindTemplateCallback("previous_match_entry", &previousMatchEntry); // Properties gobject.ext.registerProperties(class, &.{ diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index 0d2dd659b..7ca5fded7 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -23,8 +23,8 @@ template $GhosttySearchOverlay: Adw.Bin { hexpand: true; stop-search => $stop_search(); search-changed => $search_changed(); - next-match => $next_match_entry(); - previous-match => $previous_match_entry(); + next-match => $next_match(); + previous-match => $previous_match(); } Label { @@ -69,7 +69,7 @@ template $GhosttySearchOverlay: Adw.Bin { Button close_button { icon-name: "window-close-symbolic"; tooltip-text: _("Close"); - clicked => $stop_search_button(); + clicked => $stop_search(); cursor: Gdk.Cursor { name: "pointer"; diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 0abc6c356..4ebfeabfb 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -149,8 +149,6 @@ Overlay terminal_page { [overlay] $GhosttySearchOverlay search_overlay { - halign: end; - valign: start; stop-search => $search_stop(); search-changed => $search_changed(); next-match => $search_next_match(); From f7a6822e30818af306c165dcee5b13233f33db65 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 20:20:38 -0800 Subject: [PATCH 196/209] apprt/gtk: enter/shift+enter for traversing search results --- src/apprt/gtk/class/search_overlay.zig | 22 ++++++++++++++++++++++ src/apprt/gtk/ui/1.2/search-overlay.blp | 6 ++++++ 2 files changed, 28 insertions(+) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index e469e1903..192ec7ab4 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -2,6 +2,7 @@ const std = @import("std"); const adw = @import("adw"); const glib = @import("glib"); const gobject = @import("gobject"); +const gdk = @import("gdk"); const gtk = @import("gtk"); const gresource = @import("../build/gresource.zig"); @@ -204,6 +205,26 @@ pub const SearchOverlay = extern struct { signals.@"previous-match".impl.emit(self, null, .{}, null); } + fn searchEntryKeyPressed( + _: *gtk.EventControllerKey, + keyval: c_uint, + _: c_uint, + gtk_mods: gdk.ModifierType, + self: *Self, + ) callconv(.c) c_int { + if (keyval == gdk.KEY_Return or keyval == gdk.KEY_KP_Enter) { + if (gtk_mods.shift_mask) { + signals.@"previous-match".impl.emit(self, null, .{}, null); + } else { + signals.@"next-match".impl.emit(self, null, .{}, null); + } + + return 1; + } + + return 0; + } + //--------------------------------------------------------------- // Virtual methods @@ -262,6 +283,7 @@ pub const SearchOverlay = extern struct { class.bindTemplateCallback("match_label_closure", &closureMatchLabel); class.bindTemplateCallback("next_match", &nextMatch); class.bindTemplateCallback("previous_match", &previousMatch); + class.bindTemplateCallback("search_entry_key_pressed", &searchEntryKeyPressed); // Properties gobject.ext.registerProperties(class, &.{ diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index 7ca5fded7..5a011c0c9 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -25,6 +25,12 @@ template $GhosttySearchOverlay: Adw.Bin { search-changed => $search_changed(); next-match => $next_match(); previous-match => $previous_match(); + + EventControllerKey { + // We need this so we capture before the SearchEntry. + propagation-phase: capture; + key-pressed => $search_entry_key_pressed(); + } } Label { From e18a7d95014c3b762bd2fd750c794c54bfc47244 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 20:33:46 -0800 Subject: [PATCH 197/209] apprt/gtk: drag --- src/apprt/gtk/class/search_overlay.zig | 92 +++++++++++++++++++++++-- src/apprt/gtk/ui/1.2/search-overlay.blp | 12 +++- 2 files changed, 96 insertions(+), 8 deletions(-) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index 192ec7ab4..17ff5861e 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -77,6 +77,32 @@ pub const SearchOverlay = extern struct { }, ); }; + + pub const @"halign-target" = struct { + pub const name = "halign-target"; + const impl = gobject.ext.defineProperty( + name, + Self, + gtk.Align, + .{ + .default = .end, + .accessor = C.privateShallowFieldAccessor("halign_target"), + }, + ); + }; + + pub const @"valign-target" = struct { + pub const name = "valign-target"; + const impl = gobject.ext.defineProperty( + name, + Self, + gtk.Align, + .{ + .default = .start, + .accessor = C.privateShallowFieldAccessor("valign_target"), + }, + ); + }; }; pub const signals = struct { @@ -142,6 +168,12 @@ pub const SearchOverlay = extern struct { /// Currently selected match index (-1 means none selected). search_selected: i64 = -1, + /// Target horizontal alignment for the overlay. + halign_target: gtk.Align = .end, + + /// Target vertical alignment for the overlay. + valign_target: gtk.Align = .start, + pub var offset: c_int = 0; }; @@ -158,18 +190,20 @@ pub const SearchOverlay = extern struct { /// Set the total number of search matches. pub fn setSearchTotal(self: *Self, total: ?usize) void { + const priv = self.private(); const value: i64 = if (total) |t| @intCast(t) else -1; - var gvalue = gobject.ext.Value.newFrom(value); - defer gvalue.unset(); - self.as(gobject.Object).setProperty(properties.@"search-total".name, &gvalue); + if (priv.search_total == value) return; + priv.search_total = value; + self.as(gobject.Object).notifyByPspec(properties.@"search-total".impl.param_spec); } /// Set the currently selected match index. pub fn setSearchSelected(self: *Self, selected: ?usize) void { + const priv = self.private(); const value: i64 = if (selected) |s| @intCast(s) else -1; - var gvalue = gobject.ext.Value.newFrom(value); - defer gvalue.unset(); - self.as(gobject.Object).setProperty(properties.@"search-selected".name, &gvalue); + if (priv.search_selected == value) return; + priv.search_selected = value; + self.as(gobject.Object).notifyByPspec(properties.@"search-selected".impl.param_spec); } fn closureMatchLabel(_: *Self, selected: i64, total: i64) callconv(.c) ?[*:0]const u8 { @@ -225,6 +259,49 @@ pub const SearchOverlay = extern struct { return 0; } + fn onDragEnd( + _: *gtk.GestureDrag, + offset_x: f64, + offset_y: f64, + self: *Self, + ) callconv(.c) void { + // On drag end, we want to move our halign/valign if we crossed + // the midpoint on either axis. This lets the search overlay be + // moved to different corners of the parent container. + + const priv = self.private(); + const widget = self.as(gtk.Widget); + const parent = widget.getParent() orelse return; + + const parent_width: f64 = @floatFromInt(parent.getAllocatedWidth()); + const parent_height: f64 = @floatFromInt(parent.getAllocatedHeight()); + const self_width: f64 = @floatFromInt(widget.getAllocatedWidth()); + const self_height: f64 = @floatFromInt(widget.getAllocatedHeight()); + + const self_x: f64 = if (priv.halign_target == .start) 0 else parent_width - self_width; + const self_y: f64 = if (priv.valign_target == .start) 0 else parent_height - self_height; + + const new_x = self_x + offset_x + (self_width / 2); + const new_y = self_y + offset_y + (self_height / 2); + + const new_halign: gtk.Align = if (new_x > parent_width / 2) .end else .start; + const new_valign: gtk.Align = if (new_y > parent_height / 2) .end else .start; + + var changed = false; + if (new_halign != priv.halign_target) { + priv.halign_target = new_halign; + self.as(gobject.Object).notifyByPspec(properties.@"halign-target".impl.param_spec); + changed = true; + } + if (new_valign != priv.valign_target) { + priv.valign_target = new_valign; + self.as(gobject.Object).notifyByPspec(properties.@"valign-target".impl.param_spec); + changed = true; + } + + if (changed) self.as(gtk.Widget).queueResize(); + } + //--------------------------------------------------------------- // Virtual methods @@ -284,12 +361,15 @@ pub const SearchOverlay = extern struct { class.bindTemplateCallback("next_match", &nextMatch); class.bindTemplateCallback("previous_match", &previousMatch); class.bindTemplateCallback("search_entry_key_pressed", &searchEntryKeyPressed); + class.bindTemplateCallback("on_drag_end", &onDragEnd); // Properties gobject.ext.registerProperties(class, &.{ properties.active.impl, properties.@"search-total".impl, properties.@"search-selected".impl, + properties.@"halign-target".impl, + properties.@"valign-target".impl, }); // Signals diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index 5a011c0c9..dfb2d9475 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -4,8 +4,16 @@ using Adw 1; template $GhosttySearchOverlay: Adw.Bin { visible: bind template.active; - halign: end; - valign: start; + halign-target: end; + valign-target: start; + halign: bind template.halign-target; + valign: bind template.valign-target; + + GestureDrag { + button: 1; + propagation-phase: capture; + drag-end => $on_drag_end(); + } Adw.Bin { Box container { From b8393fd4aa9be2af866fb72003a9afba46451573 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 30 Nov 2025 07:04:08 -0800 Subject: [PATCH 198/209] apprt/gtk: comments --- src/apprt/gtk/class/search_overlay.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index 17ff5861e..f1e56ed37 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -185,6 +185,9 @@ pub const SearchOverlay = extern struct { pub fn grabFocus(self: *Self) void { const priv = self.private(); _ = priv.search_entry.as(gtk.Widget).grabFocus(); + + // Select all text in the search entry field. -1 is distance from + // the end, causing the entire text to be selected. priv.search_entry.as(gtk.Editable).selectRegion(0, -1); } From c67bcf969cc8bd584cb7dc8bb23c6decaebe9ad3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 30 Nov 2025 07:06:44 -0800 Subject: [PATCH 199/209] apprt/gtk: switch to has-x and optional internals for search counts --- src/apprt/gtk/class/search_overlay.zig | 120 +++++++++++++++++++----- src/apprt/gtk/ui/1.2/search-overlay.blp | 2 +- 2 files changed, 97 insertions(+), 25 deletions(-) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index f1e56ed37..2595cefa2 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -53,12 +53,33 @@ pub const SearchOverlay = extern struct { const impl = gobject.ext.defineProperty( name, Self, - i64, + u64, .{ - .default = -1, - .minimum = -1, - .maximum = std.math.maxInt(i64), - .accessor = C.privateShallowFieldAccessor("search_total"), + .default = 0, + .minimum = 0, + .maximum = std.math.maxInt(u64), + .accessor = gobject.ext.typedAccessor( + Self, + u64, + .{ .getter = getSearchTotal }, + ), + }, + ); + }; + + pub const @"has-search-total" = struct { + pub const name = "has-search-total"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ .getter = getHasSearchTotal }, + ), }, ); }; @@ -68,12 +89,33 @@ pub const SearchOverlay = extern struct { const impl = gobject.ext.defineProperty( name, Self, - i64, + u64, .{ - .default = -1, - .minimum = -1, - .maximum = std.math.maxInt(i64), - .accessor = C.privateShallowFieldAccessor("search_selected"), + .default = 0, + .minimum = 0, + .maximum = std.math.maxInt(u64), + .accessor = gobject.ext.typedAccessor( + Self, + u64, + .{ .getter = getSearchSelected }, + ), + }, + ); + }; + + pub const @"has-search-selected" = struct { + pub const name = "has-search-selected"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ .getter = getHasSearchSelected }, + ), }, ); }; @@ -162,11 +204,11 @@ pub const SearchOverlay = extern struct { /// True when a search is active, meaning we should show the overlay. active: bool = false, - /// Total number of search matches (-1 means unknown/none). - search_total: i64 = -1, + /// Total number of search matches (null means unknown/none). + search_total: ?usize = null, - /// Currently selected match index (-1 means none selected). - search_selected: i64 = -1, + /// Currently selected match index (null means none selected). + search_selected: ?usize = null, /// Target horizontal alignment for the overlay. halign_target: gtk.Align = .end, @@ -194,27 +236,55 @@ pub const SearchOverlay = extern struct { /// Set the total number of search matches. pub fn setSearchTotal(self: *Self, total: ?usize) void { const priv = self.private(); - const value: i64 = if (total) |t| @intCast(t) else -1; - if (priv.search_total == value) return; - priv.search_total = value; + const had_total = priv.search_total != null; + if (priv.search_total == total) return; + priv.search_total = total; self.as(gobject.Object).notifyByPspec(properties.@"search-total".impl.param_spec); + if (had_total != (total != null)) { + self.as(gobject.Object).notifyByPspec(properties.@"has-search-total".impl.param_spec); + } } /// Set the currently selected match index. pub fn setSearchSelected(self: *Self, selected: ?usize) void { const priv = self.private(); - const value: i64 = if (selected) |s| @intCast(s) else -1; - if (priv.search_selected == value) return; - priv.search_selected = value; + const had_selected = priv.search_selected != null; + if (priv.search_selected == selected) return; + priv.search_selected = selected; self.as(gobject.Object).notifyByPspec(properties.@"search-selected".impl.param_spec); + if (had_selected != (selected != null)) { + self.as(gobject.Object).notifyByPspec(properties.@"has-search-selected".impl.param_spec); + } } - fn closureMatchLabel(_: *Self, selected: i64, total: i64) callconv(.c) ?[*:0]const u8 { - if (total <= 0) return glib.ext.dupeZ(u8, "0/0"); + fn getSearchTotal(self: *Self) u64 { + return self.private().search_total orelse 0; + } + + fn getHasSearchTotal(self: *Self) bool { + return self.private().search_total != null; + } + + fn getSearchSelected(self: *Self) u64 { + return self.private().search_selected orelse 0; + } + + fn getHasSearchSelected(self: *Self) bool { + return self.private().search_selected != null; + } + + fn closureMatchLabel( + _: *Self, + has_selected: bool, + selected: u64, + has_total: bool, + total: u64, + ) callconv(.c) ?[*:0]const u8 { + if (!has_total or total == 0) return glib.ext.dupeZ(u8, "0/0"); var buf: [32]u8 = undefined; const label = std.fmt.bufPrintZ(&buf, "{}/{}", .{ - if (selected >= 0) selected + 1 else 0, - if (total >= 0) total else 0, + if (has_selected) selected + 1 else 0, + total, }) catch return null; return glib.ext.dupeZ(u8, label); } @@ -370,7 +440,9 @@ pub const SearchOverlay = extern struct { gobject.ext.registerProperties(class, &.{ properties.active.impl, properties.@"search-total".impl, + properties.@"has-search-total".impl, properties.@"search-selected".impl, + properties.@"has-search-selected".impl, properties.@"halign-target".impl, properties.@"valign-target".impl, }); diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index dfb2d9475..6523d4149 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -46,7 +46,7 @@ template $GhosttySearchOverlay: Adw.Bin { "dim-label", ] - label: bind $match_label_closure(template.search-selected, template.search-total) as ; + label: bind $match_label_closure(template.has-search-selected, template.search-selected, template.has-search-total, template.search-total) as ; width-chars: 6; xalign: 1.0; } From 7be28e72159c1e1be1703dc33b61a38693ee6cc7 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sun, 30 Nov 2025 17:53:21 +0100 Subject: [PATCH 200/209] core: encode mouse buttons 8 & 9 (back/forward) --- src/Surface.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index 591ee7220..40929e168 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3464,6 +3464,8 @@ fn mouseReport( .five => 65, .six => 66, .seven => 67, + .eight => 128, + .nine => 129, else => return, // unsupported }; } From 7820608b04a6d3b83b4ef428b196842210b3d4c0 Mon Sep 17 00:00:00 2001 From: rhodes-b <59537185+rhodes-b@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:32:13 -0600 Subject: [PATCH 201/209] if search has text already update the search state with matches --- src/apprt/gtk/class/search_overlay.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index 2595cefa2..ffa9174b2 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -231,6 +231,10 @@ pub const SearchOverlay = extern struct { // Select all text in the search entry field. -1 is distance from // the end, causing the entire text to be selected. priv.search_entry.as(gtk.Editable).selectRegion(0, -1); + + // update search state with the active text + const text = priv.search_entry.as(gtk.Editable).getText(); + signals.@"search-changed".impl.emit(self, null, .{text}, null); } /// Set the total number of search matches. From 3ab49fdb5fb681295670cf06a410f076794b6947 Mon Sep 17 00:00:00 2001 From: rhodes-b <59537185+rhodes-b@users.noreply.github.com> Date: Sun, 30 Nov 2025 21:06:25 -0600 Subject: [PATCH 202/209] only notify search change when widget was inactive --- src/apprt/gtk/class/search_overlay.zig | 11 +++++++---- src/apprt/gtk/class/surface.zig | 11 +++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index ffa9174b2..b193d9511 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -223,6 +223,13 @@ pub const SearchOverlay = extern struct { gtk.Widget.initTemplate(self.as(gtk.Widget)); } + /// Update search contents when widget is activated + pub fn updateSearch(self: *Self) void { + const priv = self.private(); + const text = priv.search_entry.as(gtk.Editable).getText(); + signals.@"search-changed".impl.emit(self, null, .{text}, null); + } + /// Grab focus on the search entry and select all text. pub fn grabFocus(self: *Self) void { const priv = self.private(); @@ -231,10 +238,6 @@ pub const SearchOverlay = extern struct { // Select all text in the search entry field. -1 is distance from // the end, causing the entire text to be selected. priv.search_entry.as(gtk.Editable).selectRegion(0, -1); - - // update search state with the active text - const text = priv.search_entry.as(gtk.Editable).getText(); - signals.@"search-changed".impl.emit(self, null, .{text}, null); } /// Set the total number of search matches. diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 2af53e1ef..49a6fbf42 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1964,6 +1964,17 @@ pub const Surface = extern struct { SearchOverlay.properties.active.name, &value, ); + + var is_active = gobject.ext.Value.newFrom(false); + defer is_active.unset(); + gobject.Object.getProperty( + priv.search_overlay.as(gobject.Object), + SearchOverlay.properties.active.name, + &is_active + ); + if (active and !is_active) { + priv.search_overlay.updateSearch(); + } if (active) { priv.search_overlay.grabFocus(); } From 27c82f739e9ae22a93e9cef0bb1912dd976dd0d0 Mon Sep 17 00:00:00 2001 From: rhodes-b <59537185+rhodes-b@users.noreply.github.com> Date: Sun, 30 Nov 2025 21:22:07 -0600 Subject: [PATCH 203/209] only update search when going from inactive to active --- src/apprt/gtk/class/search_overlay.zig | 30 +++++++++++++++++++------- src/apprt/gtk/class/surface.zig | 10 --------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index b193d9511..4936cd967 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -43,7 +43,14 @@ pub const SearchOverlay = extern struct { bool, .{ .default = false, - .accessor = C.privateShallowFieldAccessor("active"), + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ + .getter = getSearchActive, + .setter = setSearchActive, + }, + ), }, ); }; @@ -223,13 +230,6 @@ pub const SearchOverlay = extern struct { gtk.Widget.initTemplate(self.as(gtk.Widget)); } - /// Update search contents when widget is activated - pub fn updateSearch(self: *Self) void { - const priv = self.private(); - const text = priv.search_entry.as(gtk.Editable).getText(); - signals.@"search-changed".impl.emit(self, null, .{text}, null); - } - /// Grab focus on the search entry and select all text. pub fn grabFocus(self: *Self) void { const priv = self.private(); @@ -240,6 +240,16 @@ pub const SearchOverlay = extern struct { priv.search_entry.as(gtk.Editable).selectRegion(0, -1); } + // Set active status, and update search on activation + fn setSearchActive(self: *Self, active: bool) void { + const priv = self.private(); + if (!priv.active and active) { + const text = priv.search_entry.as(gtk.Editable).getText(); + signals.@"search-changed".impl.emit(self, null, .{text}, null); + } + priv.active = active; + } + /// Set the total number of search matches. pub fn setSearchTotal(self: *Self, total: ?usize) void { const priv = self.private(); @@ -264,6 +274,10 @@ pub const SearchOverlay = extern struct { } } + fn getSearchActive(self: *Self) bool { + return self.private().active; + } + fn getSearchTotal(self: *Self) u64 { return self.private().search_total orelse 0; } diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 49a6fbf42..a4d2d6696 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1965,16 +1965,6 @@ pub const Surface = extern struct { &value, ); - var is_active = gobject.ext.Value.newFrom(false); - defer is_active.unset(); - gobject.Object.getProperty( - priv.search_overlay.as(gobject.Object), - SearchOverlay.properties.active.name, - &is_active - ); - if (active and !is_active) { - priv.search_overlay.updateSearch(); - } if (active) { priv.search_overlay.grabFocus(); } From b776b3df6115a04faaecad05729c1456b38534cd Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 1 Dec 2025 10:19:00 -0500 Subject: [PATCH 204/209] zsh: improve minimum version check - Handle autoload failures - Prefer ">&2" to "/dev/stderr" for portability - Quote commands for consistency and to avoid alias conflicts --- src/shell-integration/zsh/.zshenv | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/shell-integration/zsh/.zshenv b/src/shell-integration/zsh/.zshenv index 3332b1c1f..4201b295c 100644 --- a/src/shell-integration/zsh/.zshenv +++ b/src/shell-integration/zsh/.zshenv @@ -43,9 +43,8 @@ fi [[ ! -r "$_ghostty_file" ]] || 'builtin' 'source' '--' "$_ghostty_file" } always { if [[ -o 'interactive' ]]; then - 'builtin' 'autoload' '--' 'is-at-least' - 'is-at-least' "5.1" || { - builtin echo "ZSH ${ZSH_VERSION} is too old for ghostty shell integration" > /dev/stderr + 'builtin' 'autoload' '--' 'is-at-least' 2>/dev/null && 'is-at-least' "5.1" || { + 'builtin' 'echo' "zsh ${ZSH_VERSION} is too old for ghostty shell integration" >&2 'builtin' 'unset' '_ghostty_file' return } From da014d98cd58f8bec540a6dc7aae24081532a3e3 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 1 Dec 2025 19:07:50 -0500 Subject: [PATCH 205/209] zsh: improve ZDOTDIR documentation The main thing to emphasize is that end users should never source .zshenv directly; it's only meant to be used as part of our shell injection environment. At the moment, there's no way to guard against accidentally use, but we can consider making e.g. GHOSTTY_SHELL_FEATURES always defined in this environment to that it can be used to differentiate the cases. In practice, it's unlikely that people actually source this .zshenv script directly, so hopefully this additional documentation clarifies things well enough. --- src/shell-integration/README.md | 2 +- src/shell-integration/zsh/.zshenv | 7 ++++++- src/termio/shell_integration.zig | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 1fd11091d..2357a64f6 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -83,7 +83,7 @@ from the `zsh` directory. The existing `ZDOTDIR` is retained so that after loading the Ghostty shell integration the normal Zsh loading sequence occurs. -```bash +```zsh if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then source "$GHOSTTY_RESOURCES_DIR"/shell-integration/zsh/ghostty-integration fi diff --git a/src/shell-integration/zsh/.zshenv b/src/shell-integration/zsh/.zshenv index 4201b295c..437e7f5c4 100644 --- a/src/shell-integration/zsh/.zshenv +++ b/src/shell-integration/zsh/.zshenv @@ -15,11 +15,16 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +# This script is sourced automatically by zsh when ZDOTDIR is set to this +# directory. It therefore assumes it's running within our shell integration +# environment and should not be sourced manually (unlike ghostty-integration). +# # This file can get sourced with aliases enabled. To avoid alias expansion # we quote everything that can be quoted. Some aliases will still break us # though. -# Restore the original ZDOTDIR value. +# Restore the original ZDOTDIR value if GHOSTTY_ZSH_ZDOTDIR is set. +# Otherwise, unset the ZDOTDIR that was set during shell injection. if [[ -n "${GHOSTTY_ZSH_ZDOTDIR+X}" ]]; then 'builtin' 'export' ZDOTDIR="$GHOSTTY_ZSH_ZDOTDIR" 'builtin' 'unset' 'GHOSTTY_ZSH_ZDOTDIR' diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 8b2648dbd..c2a637b80 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -659,12 +659,12 @@ fn setupZsh( resource_dir: []const u8, env: *EnvMap, ) !void { - // Preserve the old zdotdir value so we can recover it. + // Preserve an existing ZDOTDIR value. We're about to overwrite it. if (env.get("ZDOTDIR")) |old| { try env.put("GHOSTTY_ZSH_ZDOTDIR", old); } - // Set our new ZDOTDIR + // Set our new ZDOTDIR to point to our shell resource directory. var path_buf: [std.fs.max_path_bytes]u8 = undefined; const integ_dir = try std.fmt.bufPrint( &path_buf, From 7fe3f5cd3f39f0566ff5ff9babca35e3b91a6675 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Mon, 1 Dec 2025 18:23:37 -0600 Subject: [PATCH 206/209] build: fix path access to work with relative build roots Replace std.fs.accessAbsolute(b.pathFromRoot(...)) with b.build_root.handle.access(...) since pathFromRoot can return relative paths, but accessAbsolute asserts the path is absolute. --- src/build/GhosttyDist.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/build/GhosttyDist.zig b/src/build/GhosttyDist.zig index 092322689..600aa4883 100644 --- a/src/build/GhosttyDist.zig +++ b/src/build/GhosttyDist.zig @@ -170,11 +170,11 @@ pub const Resource = struct { /// Returns true if the dist path exists at build time. pub fn exists(self: *const Resource, b: *std.Build) bool { - if (std.fs.accessAbsolute(b.pathFromRoot(self.dist), .{})) { + if (b.build_root.handle.access(self.dist, .{})) { // If we have a ".git" directory then we're a git checkout // and we never want to use the dist path. This shouldn't happen // so show a warning to the user. - if (std.fs.accessAbsolute(b.pathFromRoot(".git"), .{})) { + if (b.build_root.handle.access(".git", .{})) { std.log.warn( "dist resource '{s}' should not be in a git checkout", .{self.dist}, From 6babcc97f59b1e02d07379228b578bbdb76ef0fb Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 1 Dec 2025 20:34:55 -0500 Subject: [PATCH 207/209] zsh: move version check to ghostty-integration The ghostty-integration script can be manually sourced, and it uses the Zsh 5.1+ features, so that's a better place to guard against older Zsh versions. This also keeps the .zshenv script focused on just bootstrapping our automatic shell integration. I also changed the version check to a slightly more idiomatic pattern. --- src/shell-integration/README.md | 2 ++ src/shell-integration/zsh/.zshenv | 5 ----- src/shell-integration/zsh/ghostty-integration | 9 +++++++++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 1fd11091d..2ac388644 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -88,3 +88,5 @@ if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then source "$GHOSTTY_RESOURCES_DIR"/shell-integration/zsh/ghostty-integration fi ``` + +Shell integration requires Zsh 5.1+. diff --git a/src/shell-integration/zsh/.zshenv b/src/shell-integration/zsh/.zshenv index 4201b295c..4ed96cd79 100644 --- a/src/shell-integration/zsh/.zshenv +++ b/src/shell-integration/zsh/.zshenv @@ -43,11 +43,6 @@ fi [[ ! -r "$_ghostty_file" ]] || 'builtin' 'source' '--' "$_ghostty_file" } always { if [[ -o 'interactive' ]]; then - 'builtin' 'autoload' '--' 'is-at-least' 2>/dev/null && 'is-at-least' "5.1" || { - 'builtin' 'echo' "zsh ${ZSH_VERSION} is too old for ghostty shell integration" >&2 - 'builtin' 'unset' '_ghostty_file' - return - } # ${(%):-%x} is the path to the current file. # On top of it we add :A:h to get the directory. 'builtin' 'typeset' _ghostty_file="${${(%):-%x}:A:h}"/ghostty-integration diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 27ef39bbc..7ff43efd9 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -1,3 +1,5 @@ +# vim:ft=zsh +# # Based on (started as) a copy of Kitty's zsh integration. Kitty is # distributed under GPLv3, so this file is also distributed under GPLv3. # The license header is reproduced below: @@ -41,6 +43,13 @@ _entrypoint() { [[ -o interactive ]] || builtin return 0 # non-interactive shell (( ! $+_ghostty_state )) || builtin return 0 # already initialized + # We require zsh 5.1+ (released Sept 2015) for features like functions_source, + # introspection arrays, and array pattern substitution. + if ! { builtin autoload -- is-at-least 2>/dev/null && is-at-least 5.1; }; then + builtin echo "Zsh ${ZSH_VERSION} is too old for ghostty shell integration (5.1+ required)" >&2 + builtin return 1 + fi + # 0: no OSC 133 [AC] marks have been written yet. # 1: the last written OSC 133 C has not been closed with D yet. # 2: none of the above. From 56d4e6d955906255b727593d106099eb210941fb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 03:08:36 +0000 Subject: [PATCH 208/209] build(deps): bump softprops/action-gh-release from 2.4.2 to 2.5.0 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.4.2 to 2.5.0. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/5be0e66d93ac7ed76da52eca8bb058f665c3a5fe...a06a81a03ee405af7f2048a818ed3f03bbf83c7b) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: 2.5.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/release-tip.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index a8a7f641f..f88df3440 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -186,7 +186,7 @@ jobs: nix develop -c minisign -S -m ghostty-source.tar.gz -s minisign.key < minisign.password - name: Update Release - uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -356,7 +356,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -583,7 +583,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -767,7 +767,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true From d926bd5376c582e83874be374630f1d19f53785f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 00:07:23 +0000 Subject: [PATCH 209/209] build(deps): bump actions/checkout from 6.0.0 to 6.0.1 Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.0 to 6.0.1. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/1af3b93b6815bc44a9784bd300feb67ff0d1eeb3...8e8c483db84b4bee98b60c0593521ed34d9990e8) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/release-tag.yml | 8 +-- .github/workflows/release-tip.yml | 18 +++---- .github/workflows/test.yml | 62 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 5 files changed, 46 insertions(+), 46 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index d8b9d2c18..825cf52f5 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -34,7 +34,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 50892a151..82970a065 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -56,7 +56,7 @@ jobs: fi - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Important so that build number generation works fetch-depth: 0 @@ -80,7 +80,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -132,7 +132,7 @@ jobs: GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }} steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - uses: DeterminateSystems/nix-installer-action@main with: @@ -306,7 +306,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Download macOS Artifacts uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index f88df3440..df73198d1 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -29,7 +29,7 @@ jobs: commit: ${{ steps.extract_build_info.outputs.commit }} commit_long: ${{ steps.extract_build_info.outputs.commit_long }} steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Important so that build number generation works fetch-depth: 0 @@ -66,7 +66,7 @@ jobs: needs: [setup, build-macos] if: needs.setup.outputs.should_skip != 'true' steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Tip Tag run: | git config user.name "github-actions[bot]" @@ -81,7 +81,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Install sentry-cli run: | @@ -104,7 +104,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Install sentry-cli run: | @@ -127,7 +127,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Install sentry-cli run: | @@ -159,7 +159,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -217,7 +217,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Important so that build number generation works fetch-depth: 0 @@ -451,7 +451,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Important so that build number generation works fetch-depth: 0 @@ -635,7 +635,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Important so that build number generation works fetch-depth: 0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9b6acd385..916745f58 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,7 +69,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -112,7 +112,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -145,7 +145,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -179,7 +179,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -222,7 +222,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -258,7 +258,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -287,7 +287,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -320,7 +320,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -366,7 +366,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -404,7 +404,7 @@ jobs: needs: [build-dist, build-snap] steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Trigger Snap workflow run: | @@ -421,7 +421,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -464,7 +464,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -509,7 +509,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # This could be from a script if we wanted to but inlining here for now # in one place. @@ -580,7 +580,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Get required Zig version id: zig @@ -627,7 +627,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -675,7 +675,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -710,7 +710,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -737,7 +737,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -774,7 +774,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -804,7 +804,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -832,7 +832,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -859,7 +859,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -886,7 +886,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -913,7 +913,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -940,7 +940,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -974,7 +974,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -1001,7 +1001,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -1035,7 +1035,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -1104,7 +1104,7 @@ jobs: runs-on: ${{ matrix.variant.runner }} needs: test steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6 with: bundle: com.mitchellh.ghostty @@ -1123,7 +1123,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -1162,7 +1162,7 @@ jobs: # timeout-minutes: 10 # steps: # - name: Checkout Ghostty - # uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + # uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # # - name: Start SSH # run: | diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index b641c0bc9..b9ff89c35 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -17,7 +17,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0